# Events

Source: https://www.pitchkitjs.com/docs/data/statsbomb/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.

```tsx
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 { 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" 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:

```ts
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:

```ts
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.

<Callout type="warn">
  Events files are large — a match is roughly 3 MB. Fetch once and cache; don't call
  `fetchMatchEvents` per render.
</Callout>

## 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**:

```ts
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:

```ts
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:

```ts
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:

```ts
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.

<Callout type="warn">
  **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.)
</Callout>

## Reading the fields

Once narrowed, you're reading StatsBomb's own structure — no translation layer:

```tsx
<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)](https://github.com/statsbomb/open-data/blob/master/doc/Open%20Data%20Events%20v4.0.0.pdf)**
  — every event type and qualifier, defined by StatsBomb.
* **[Open Data Specification v1.1 (PDF)](https://github.com/statsbomb/open-data/blob/master/doc/StatsBomb%20Open%20Data%20Specification%20v1.1.pdf)**
  — the file layout, plus the competitions, matches and lineups schemas.
* **[statsbomb/open-data](https://github.com/statsbomb/open-data)** — the repository these
  loaders fetch from.
* **[StatsBomb free data hub](https://statsbomb.com/what-we-do/hub/free-data/)** and the
  [usage terms](https://statsbomb.com/what-we-do/hub/free-data/free-data-usage-terms/) — read
  the terms before you publish anything from it.

## Next

[360 tracking](/docs/data/statsbomb/360) — every player's position at the moment of an event,
joined onto these same events.
