PitchKit
DataStatsBomb

360 Tracking

Every visible player's position at the moment of an event — joined onto the events feed.

StatsBomb 360 is optical tracking: for events the broadcast camera could see, a freeze frame of where every visible player was standing. It's a separate file per match, joined onto the events feed by id.

Pick a match below and step through it two moments at a time — each pitch is one tracked event, with a Voronoi diagram of the space each player was closest to. Both files load together, so give it a moment: that's around 10 MB of real data.

Fetching events + 360 tracking from StatsBomb open data (~10 MB)…

"use client";import { useEffect, useState } from "react";import { Polygon, Scatter, VerticalPitch, Voronoi } from "@pitchkit/react";import {  fetchMatchEvents,  fetchMatchThreeSixty,  indexThreeSixtyByEvent,  isKeeper,  visibleAreaPolygon,} from "@pitchkit/data-providers/statsbomb";import type {  StatsBombEvent,  StatsBombThreeSixtyFrame,  StatsBombThreeSixtyPlayer,} from "@pitchkit/data-providers/statsbomb";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, controlClass, matchLabel, useEuroMatches } from "./statsbomb-live";const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const;interface Moment {  event: StatsBombEvent;  frame: StatsBombThreeSixtyFrame;}/** 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>    </>  );}/** * The two files joined into one list: every event that has both a location * and a 360 frame, in StatsBomb's own play order (`index`). */function join(  events: readonly StatsBombEvent[],  frames: readonly StatsBombThreeSixtyFrame[],): Moment[] {  const frameByEvent = indexThreeSixtyByEvent(frames);  const moments: Moment[] = [];  for (const event of events) {    if (typeof event.x !== "number") continue;    const frame = frameByEvent.get(event.id);    if (frame) moments.push({ event, frame });  }  return moments.sort((a, b) => a.event.index - b.event.index);}/** * `teammate` is relative to whoever performed the current event, so left * alone the colours would swap sides on every change of possession. * Resolving it to the match's real team names keeps a colour meaning one * team throughout. */function colorOf(player: StatsBombThreeSixtyPlayer, moment: Moment, teams: string[]): string {  const team = player.teammate    ? moment.event.team.name    : teams.find((name) => name !== moment.event.team.name);  return team === teams[0] ? TEAM_COLORS[0] : TEAM_COLORS[1];}/** * `minute` runs continuously across periods (a 92nd-minute event really is * `minute: 92`), so mm:ss needs no stoppage-time special case. */function clockLabel(event: StatsBombEvent): string {  return `${String(event.minute).padStart(2, "0")}:${String(event.second).padStart(2, "0")}`;}/** One tracked moment: who was where, and the space each player was closest to. */function MomentPitch({ moment, teams }: { moment: Moment; teams: string[] }) {  const fill = (player: StatsBombThreeSixtyPlayer) => colorOf(player, moment, teams);  return (    <figure className="m-0">      <VerticalPitch type="statsbomb" appearance={docsAppearance}>        <Polygon          data={[moment.frame]}          points={(frame: StatsBombThreeSixtyFrame) => visibleAreaPolygon(frame)}          fill="none"          stroke="rgba(255,255,255,0.15)"          strokeWidth={1}        />        <Voronoi          data={moment.frame.freeze_frame}          x={(player) => player.x}          y={(player) => player.y}          fill={fill}          fillOpacity={0.14}          stroke="rgba(255,255,255,0.2)"          strokeWidth={0.5}        />        <Scatter          data={moment.frame.freeze_frame}          x={(player) => player.x}          y={(player) => player.y}          r={(player) => (player.actor ? 5.5 : isKeeper(player) ? 5 : 3.5)}          fill={fill}          stroke={(player) => (player.actor ? "white" : "rgba(255,255,255,0.7)")}          strokeWidth={(player) => (player.actor ? 2.5 : 1)}          tooltip={(player) =>            player.actor ? "On the ball" : isKeeper(player) ? "Goalkeeper" : undefined          }        />      </VerticalPitch>      <figcaption className="mt-1 text-xs tabular-nums text-fd-muted-foreground">        {clockLabel(moment.event)} · {moment.event.type.name} · {moment.event.team.name}      </figcaption>    </figure>  );}/** * Two consecutive tracked moments, side by side — step through the match a * pair at a time. * * Both files are fetched together, so picking a match pulls around 10 MB. */export function Statsbomb360Basic() {  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; moments: Moment[]; teams: string[] } | undefined  >();  const [failed, setFailed] = useState(false);  const [at, setAt] = useState(0);  const loaded = result?.key === matchId ? result : undefined;  useEffect(() => {    Promise.all([fetchMatchEvents(matchId), fetchMatchThreeSixty(matchId)])      .then(([events, frames]) =>        setResult({          key: matchId,          moments: join(events, frames),          teams: [...new Set(events.map((event) => event.team.name))],        }),      )      .catch(() => setFailed(true));  }, [matchId]);  const moments = loaded?.moments ?? [];  const pair = moments.slice(at, at + 2);  return (    <div>      <MatchSelector        value={matchId}        onChange={(next) => {          setFailed(false);          setAt(0);          setMatchId(next);        }}        status={          failed            ? "Couldn't reach StatsBomb open data."            : loaded === undefined              ? "Fetching events + 360 tracking from StatsBomb open data (~10 MB)…"              : `${moments.length} tracked moments · showing ${at + 1}–${at + pair.length}`        }      />      {pair.length > 0 && (        <>          <div className="grid gap-4 sm:grid-cols-2">            {pair.map((moment) => (              <MomentPitch key={moment.event.id} moment={moment} teams={loaded?.teams ?? []} />            ))}          </div>          <div className="mt-3 flex gap-2">            <button              type="button"              className={`px-3 font-medium hover:bg-fd-accent ${controlClass}`}              disabled={at === 0}              onClick={() => setAt((current) => Math.max(0, current - 2))}            >              ← Previous            </button>            <button              type="button"              className={`px-3 font-medium hover:bg-fd-accent ${controlClass}`}              disabled={at + 2 >= moments.length}              onClick={() => setAt((current) => Math.min(moments.length - 1, current + 2))}            >              Next →            </button>          </div>        </>      )}    </div>  );}

Loading and joining

Two files, one key: a frame's event_uuid is the matching event's id.

import {
  fetchMatchEvents,
  fetchMatchThreeSixty,
  indexThreeSixtyByEvent,
} from "@pitchkit/data-providers/statsbomb";

const [events, frames] = await Promise.all([
  fetchMatchEvents(3943043),
  fetchMatchThreeSixty(3943043),
]);

const frameByEvent = indexThreeSixtyByEvent(frames);
const frame = frameByEvent.get(someEvent.id); // undefined if this event wasn't tracked

indexThreeSixtyByEvent builds a Map once so lookups are cheap — much better than .find()-ing the frames array per event when you're walking a whole match.

Check availability before fetching. Most matches have no 360 data at all. A competition having some coverage doesn't mean every match in it does, so test the per-match field rather than reacting to a 404:

const matches = await fetchMatches(55, 282);
const tracked = matches.filter((m) => m.match_status_360 === "available");

360 files are also bigger than events — around 7 MB against 3 MB.

Coverage within a tracked match isn't total either. It varies by match — 85% of events in one sampled World Cup fixture — so treat a missing frame as normal, not exceptional.

What's in a frame

interface StatsBombThreeSixtyFrame {
  event_uuid: string;
  visible_area: number[]; // flat [x0, y0, x1, y1, ...] camera polygon
  freeze_frame: StatsBombThreeSixtyPlayer[];
}

interface StatsBombThreeSixtyPlayer {
  teammate: boolean; // relative to the event's own team
  actor: boolean; // the player performing the event
  keeper: boolean;
  location: number[];
  x: number; // lifted from location, for accessors
  y: number;
}

A tracked player has no identity — no name, no id, no shirt number. 360 is optical tracking, not event annotation, so all you get is which side they're on relative to the acting player. That's the source data, and the types don't pretend otherwise.

Selectors read a frame's players:

import { teammatesIn, opponentsIn, actorIn, keeperIn } from "@pitchkit/data-providers/statsbomb";

teammatesIn(frame); // the acting player's side, including the actor
opponentsIn(frame); // the other side
actorIn(frame); // whoever performed the event
keeperIn(frame); // the tracked keeper, if one was in view

with isTeammate / isOpponent / isActor / isKeeper as the underlying predicates if you'd rather filter yourself.

Plotting a frame

Any layer that takes points works directly, because x/y are already lifted:

<Pitch type="statsbomb">
  <Voronoi data={frame.freeze_frame} x={(p) => p.x} y={(p) => p.y} />
  <Scatter data={frame.freeze_frame} x={(p) => p.x} y={(p) => p.y} r={3.5} />
</Pitch>

visible_area is the pitch region the camera actually covered — the freeze_frame only lists players inside it. It arrives in StatsBomb's flat encoding, so there's a helper to pair it up for a <Polygon>:

import { visibleAreaPolygon } from "@pitchkit/data-providers/statsbomb";

<Polygon data={[frame]} points={(f) => visibleAreaPolygon(f)} fill="none" stroke="white" />;

Keeping team colours stable

One thing to watch when you show more than one frame: teammate is relative to whoever performed that specific event. Colour straight off it and the two sides swap palettes on every change of possession, so the same colour means different teams on adjacent pitches.

Resolve it against the event's own team once, and a colour means one team throughout:

function realTeamOf(player, event, teams) {
  if (player.teammate) return event.team.name;
  return teams.find((name) => name !== event.team.name);
}

That's exactly what the example above does — its full source is in the View Code panel.

Official documentation

On this page