PitchKit
DataStatsBomb

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 intact

Shots, 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:

Next

360 tracking — every player's position at the moment of an event, joined onto these same events.

On this page