Events
Competitions, matches and events from StatsBomb open data — typed, filtered, plotted.
Everything below is live: the example fetches a real Euro 2024 match from StatsBomb's open-data repository in your browser, and the match picker switches between all 51 of them.
Fetching the match from StatsBomb open data (~3 MB)…
"use client";import { useEffect, useState } from "react";import { cropForHalf, getPitchDimensions } from "@pitchkit/core";import { Scatter, VerticalPitch } from "@pitchkit/react";import { fetchMatchEvents, isGoal, shots } from "@pitchkit/data-providers/statsbomb";import type { StatsBombShot } from "@pitchkit/data-providers/statsbomb";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, controlClass, matchLabel, useEuroMatches } from "./statsbomb-live";const dimensions = getPitchDimensions("statsbomb");/** Every Euro 2024 fixture, in kickoff order, over a line of status text. */function MatchSelector({ value, onChange, status,}: { value: number; onChange: (matchId: number) => void; status: string;}) { const matches = useEuroMatches(); return ( <> <select aria-label="Euro 2024 match" value={value} disabled={matches.length === 0} onChange={(event) => onChange(Number(event.target.value))} className={`w-full min-w-0 pl-2 pr-8 sm:w-auto sm:max-w-xs ${controlClass}`} > {matches.length === 0 && <option value={DEFAULT_MATCH_ID}>Loading matches…</option>} {matches.map((match) => ( <option key={match.match_id} value={match.match_id}> {matchLabel(match)} </option> ))} </select> <p className="my-3 text-xs text-fd-muted-foreground">{status}</p> </> );}/** * A shot map built from a real Euro 2024 match, fetched in the browser. * * The data path is two lines: fetch the match, narrow to shots. After that * you're reading StatsBomb's own fields — `shot.statsbomb_xg`, * `shot.outcome.name` — with `x`/`y` already lifted into place for the * `<Scatter>` accessors. */export function StatsbombEventsBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); // Keyed by the match it belongs to, so "still loading" is derived rather // than a second state field. const [result, setResult] = useState<{ key: number; shots: StatsBombShot[] } | undefined>(); const [failed, setFailed] = useState(false); const loaded = result?.key === matchId ? result.shots : undefined; useEffect(() => { fetchMatchEvents(matchId) .then((events) => setResult({ key: matchId, shots: shots(events) })) .catch(() => setFailed(true)); }, [matchId]); return ( <div> <MatchSelector value={matchId} onChange={(next) => { setFailed(false); setMatchId(next); }} status={ failed ? "Couldn't reach StatsBomb open data." : loaded === undefined ? "Fetching the match from StatsBomb open data (~3 MB)…" : `${loaded.length} shots · ${loaded.filter(isGoal).length} goals` } /> <VerticalPitch type="statsbomb" appearance={docsAppearance} crop={cropForHalf(dimensions)}> <Scatter data={loaded ?? []} x={(shot) => shot.x} y={(shot) => shot.y} r={(shot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 11} fill={(shot) => isGoal(shot) ? "var(--pitch-marker-goal)" : "var(--pitch-marker-primary)" } fillOpacity={(shot) => (isGoal(shot) ? 0.95 : 0.55)} stroke="white" strokeWidth={(shot) => (isGoal(shot) ? 2 : 1)} tooltip={(shot) => `${shot.player?.name ?? "Unknown"} (${shot.team.name}) — ${shot.shot.outcome.name}, ${shot.shot.statsbomb_xg.toFixed(2)} xG` } /> </VerticalPitch> </div> );}Getting a match
competitions.json lists competition-and-season pairs, not competitions — the same
competition appears once per season available. That's why fetchMatches needs both ids:
import {
fetchCompetitions,
fetchMatches,
fetchMatchEvents,
} from "@pitchkit/data-providers/statsbomb";
const competitions = await fetchCompetitions(); // 80 competition-seasons
const matches = await fetchMatches(55, 282); // Euro 2024: competition 55, season 282
const events = await fetchMatchEvents(matches[0].match_id);Every fetch takes optional { baseUrl, fetch, signal }, so you can point at a mirror or wrap
the request — Next.js caching, a proxy agent, a stub in tests:
const events = await fetchMatchEvents(3943043, {
fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 86400 } }),
});If you already have the JSON, skip the network entirely with parseEvents(json), or point
loadEvents(url) at wherever you keep it.
Events files are large — a match is roughly 3 MB. Fetch once and cache; don't call
fetchMatchEvents per render.
Narrowing the feed
A match's events are a mixed list of ~3,500 items. The selectors narrow it, and they're the only thing that narrows the type:
import { shots, passes, carries, ofType } from "@pitchkit/data-providers/statsbomb";
shots(events); // StatsBombShot[]
passes(events); // StatsBombPass[]
carries(events); // StatsBombCarry[]
ofType(events, "Duel"); // everything else, untyped but intactShots, passes and carries are typed explicitly. Every other event type — Duel, Dribble,
Pressure, Goal Keeper, and the rest — comes back from ofType as a generic event with its
own sub-object still attached. Nothing is dropped.
Why event.type.name === "Shot" doesn't narrow
StatsBomb's discriminant is nested one level down, inside type. TypeScript only narrows
unions on top-level literal discriminants, so this compiles and runs correctly but fails to
typecheck:
if (event.type.name === "Shot") {
event.shot.statsbomb_xg; // ✗ Property 'shot' does not exist on type 'StatsBombEvent'
}Use the guards, which narrow properly — and which also check the sub-object is really present rather than trusting the name:
import { isShot } from "@pitchkit/data-providers/statsbomb";
if (isShot(event)) {
event.shot.statsbomb_xg; // ✓
}Hoisting a top-level discriminant would fix this, but only by inventing a field StatsBomb doesn't have — which is exactly what this package avoids.
Predicates
Composable filters over StatsBomb's own fields. They apply to the narrowed types, so they chain off a selector:
import {
passes,
shots,
isCorner,
isCross,
isGoal,
isOnTarget,
} from "@pitchkit/data-providers/statsbomb";
const corners = passes(events).filter(isCorner);
const crosses = passes(events).filter(isCross);
const goals = shots(events).filter(isGoal);
const onTarget = shots(events).filter(isOnTarget);Passes: isComplete, isCorner, isFreeKick, isThrowIn, isCross, isThroughBall,
isSwitch, isAssist, isKeyPass. Shots: isGoal, isPenalty, isOnTarget. isSetPiece
takes either.
A completed pass has no outcome at all. StatsBomb encodes success as the absence of
pass.outcome, not as a value — so outcome.name === "Complete" matches nothing, ever. That's
what isComplete is for. (In one sampled match, 984 of 1,163 passes had no outcome key.)
Reading the fields
Once narrowed, you're reading StatsBomb's own structure — no translation layer:
<Scatter
data={shots(events)}
x={(shot) => shot.x} // lifted from location[0]
y={(shot) => shot.y} // lifted from location[1]
r={(shot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 11}
fill={(shot) => (isGoal(shot) ? "orange" : "steelblue")}
tooltip={(shot) => `${shot.player?.name} — ${shot.shot.outcome.name}`}
/>x/y (and endX/endY on shots, passes and carries) are the only additions the parser
makes. endZ exists on shots only when the ball left the ground — StatsBomb writes a
two-element end_location otherwise, which is why it's optional.
A handful of event types genuinely carry no location at all — Starting XI, Half Start,
Substitution, Tactical Shift — so x/y are optional on the base event type and required
on shots, passes and carries.
Official documentation
This page covers loading the data; StatsBomb's own specification is the authority on what each field means:
- Open Data Events v4.0.0 (PDF) — every event type and qualifier, defined by StatsBomb.
- Open Data Specification v1.1 (PDF) — the file layout, plus the competitions, matches and lineups schemas.
- statsbomb/open-data — the repository these loaders fetch from.
- StatsBomb free data hub and the usage terms — read the terms before you publish anything from it.
Next
360 tracking — every player's position at the moment of an event, joined onto these same events.