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 trackedindexThreeSixtyByEvent 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 viewwith 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
- Open Data 360 Frames v1.0.0 (PDF)
— StatsBomb's specification for freeze frames and
visible_area. (The%281%29is the literal(1)in StatsBomb's own filename, percent-encoded so the link parser can't end the URL on it.) - Open Data Specification v1.1 (PDF) — the file layout these frames sit in.
- statsbomb/open-data — the repository, and the
doc/folder holding every spec. - StatsBomb free data hub and the usage terms — using this data obliges you to credit StatsBomb.