Tracking
Broadcast tracking at 10 fps — streamed out of a 90 MB file, and plotted in SkillCorner's own metres.
SkillCorner's tracking is broadcast tracking: computer vision run over the TV feed, giving every visible player's position ten times a second. The example below streams half a minute of a real match and plays it back in real time.
Streaming 300 frames out of a ~90 MB tracking file…
"use client";import { useEffect, useState } from "react";import type { ReactNode } from "react";import { Pitch, Scatter, Voronoi } from "@pitchkit/react";import { fetchMatch, fetchMatches, streamTracking } from "@pitchkit/data-providers/skillcorner";import type { SkillCornerFrame, SkillCornerMatch, SkillCornerMatchSummary,} from "@pitchkit/data-providers/skillcorner";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, matchLabel, selectClass } from "./skillcorner-live";/** 10 fps is the data's own rate, so this plays back in real time. */const FPS = 10;/** 30 seconds of football — enough for a phase of play, ~2 MB of a 90 MB file. */const CLIP_FRAMES = 300;const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const;interface Clip { readonly match: SkillCornerMatch; readonly frames: readonly SkillCornerFrame[]; /** player_id → true when that player is on the home team. */ readonly isHome: ReadonlyMap<number, boolean>;}/** * Stream a clip out of a match's tracking file. * * A full file is ~90 MB at 10 fps. `streamTracking` is an async generator, so * leaving the loop closes the reader and **aborts the download** — this pulls * roughly 2 MB and stops. That is the whole trick, and it's why tracking data * is usable in a browser at all. */async function streamClip(matchId: number, signal: AbortSignal): Promise<Clip> { const match = await fetchMatch(matchId, { signal }); const frames: SkillCornerFrame[] = []; for await (const frame of streamTracking(match, { signal })) { // Before kickoff every field is null and `player_data` is empty — the // file's own shape, not a parse failure. if (frame.period === null || frame.player_data.length === 0) continue; frames.push(frame); if (frames.length >= CLIP_FRAMES) break; // ← stops the download } // Tracking carries only `player_id` — no name, no team — so the match file // is what turns a position into a side. The join key is `players[].id`, // **not** `trackable_object`, which is a different id space entirely. const isHome = new Map( match.players.map((player) => [player.id, player.team_id === match.home_team.id]), ); return { match, frames, isHome };}/** Loads a clip whenever the chosen match changes, and cancels the last one. */function useClip(matchId: number) { const [clip, setClip] = useState<{ key: number; value: Clip } | undefined>(); const [failed, setFailed] = useState(false); useEffect(() => { const controller = new AbortController(); streamClip(matchId, controller.signal) .then((value) => setClip({ key: matchId, value })) .catch(() => { if (!controller.signal.aborted) setFailed(true); }); return () => controller.abort(); }, [matchId]); return { clip: clip?.key === matchId ? clip.value : undefined, failed };}/** Advances a frame index at a fixed rate, looping at the end. */function usePlayhead(length: number) { const [at, setAt] = useState(0); useEffect(() => { if (length === 0) return; const id = setInterval(() => setAt((current) => (current + 1) % length), 1000 / FPS); return () => clearInterval(id); }, [length]); return Math.min(at, Math.max(length - 1, 0));}/** Match picker and status line — the chrome, kept out of the way. */function MatchPicker({ value, onChange, status,}: { value: number; onChange: (id: number) => void; status: ReactNode;}) { const [matches, setMatches] = useState<SkillCornerMatchSummary[]>([]); useEffect(() => { fetchMatches() .then(setMatches) .catch(() => undefined); }, []); return ( <> <select aria-label="SkillCorner match" value={value} disabled={matches.length === 0} onChange={(event) => onChange(Number(event.target.value))} className={selectClass} > {matches.length === 0 && <option value={DEFAULT_MATCH_ID}>Loading matches…</option>} {matches.map((match) => ( <option key={match.id} value={match.id}> {matchLabel(match)} </option> ))} </select> <p className="my-3 text-xs text-fd-muted-foreground">{status}</p> </> );}/** * A streamed clip of SkillCorner broadcast tracking, playing at 10 fps. * * Coordinates go in **raw**: `<Pitch type="skillcorner">` uses SkillCorner's * own centre-origin metres, so the accessors are just `(p) => p.x`. The * `dimensions` prop draws this stadium's real pitch — they run 104 to 106 m. */export function SkillcornerTrackingBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); const { clip, failed } = useClip(matchId); const at = usePlayhead(clip?.frames.length ?? 0); const frame = clip?.frames[at]; const fill = (player: { player_id: number }) => clip?.isHome.get(player.player_id) ? TEAM_COLORS[0] : TEAM_COLORS[1]; return ( <div> <MatchPicker value={matchId} onChange={setMatchId} status={ failed ? "Couldn't reach SkillCorner open data." : clip === undefined ? `Streaming ${CLIP_FRAMES} frames out of a ~90 MB tracking file…` : `${clip.frames.length} frames · ${clip.match.pitch_length}×${clip.match.pitch_width} m pitch · playing at ${FPS} fps` } /> <Pitch type="skillcorner" dimensions={clip && { length: clip.match.pitch_length, width: clip.match.pitch_width }} appearance={docsAppearance} > {frame && ( <> <Voronoi data={frame.player_data} x={(player) => player.x} y={(player) => player.y} fill={fill} fillOpacity={0.13} stroke="rgba(255,255,255,0.18)" strokeWidth={0.4} /> <Scatter data={frame.player_data} x={(player) => player.x} y={(player) => player.y} r={2.4} fill={fill} // Broadcast tracking only sees what the camera framed; the rest // is extrapolated between sightings, and `is_detected` says which. fillOpacity={(player) => (player.is_detected ? 1 : 0.25)} stroke={fill} strokeWidth={0.7} /> {frame.ball_data.x !== null && frame.ball_data.y !== null && ( <Scatter data={[frame.ball_data]} x={(ball) => ball.x ?? 0} y={(ball) => ball.y ?? 0} r={1.4} fill="#fff" stroke="#111" strokeWidth={0.4} /> )} </> )} </Pitch> </div> );}The size problem, and the answer
One match's tracking file is about 90 MB. Downloading it to draw thirty seconds would be
absurd, so streamTracking is an async generator — leaving the loop closes the reader,
which aborts the response mid-download:
import { fetchMatch, streamTracking } from "@pitchkit/data-providers/skillcorner";
const match = await fetchMatch(1874553);
const frames = [];
for await (const frame of streamTracking(match)) {
if (frame.period === null || frame.player_data.length === 0) continue;
frames.push(frame);
if (frames.length >= 300) break; // ← stops the download
}Measured in a browser against the real file: 1.9 MB of 86.5 MB, 2.2%. If you'd rather jump
into the middle of a match, fetchTrackingWindow(match, { fromFrame, toFrame }) does an HTTP
Range read instead, estimating the byte offset and filtering to the frames you asked for.
Tracking is served from a different host. These files are stored with Git LFS, so
raw.githubusercontent.com returns a ~130-byte pointer stub rather than data — which fails as
"not valid JSON" on a file that looks perfectly fine in a browser. The loaders already point at
media.githubusercontent.com for tracking and the raw host for everything else. If you mirror the
data yourself, {baseUrl} and {lfsBaseUrl} are separate options for this reason.
Plotting it
Coordinates go in raw. SkillCorner measures in metres from the centre spot, and
<Pitch type="skillcorner"> uses that same grid, so the accessors are just (p) => p.x:
<Pitch type="skillcorner" dimensions={{ length: match.pitch_length, width: match.pitch_width }}>
<Voronoi data={frame.player_data} x={(p) => p.x} y={(p) => p.y} />
<Scatter data={frame.player_data} x={(p) => p.x} y={(p) => p.y} r={2.4} />
</Pitch>dimensions is worth passing: SkillCorner pitches are real stadium pitches, and the open data
spans 104, 105 and 106 m. Markings don't scale with it — a penalty area is 16.5 m deep on
any pitch — so only the outline, halfway line and goal lines move.
What's in a frame
interface SkillCornerFrame {
frame: number;
timestamp: string | null; // "00:43:32.00", null before kickoff
period: number | null;
ball_data: { x: number | null; y: number | null; z: number | null; is_detected: boolean | null };
possession: { player_id: number | null; group: string | null };
player_data: SkillCornerTrackedPlayer[];
}
interface SkillCornerTrackedPlayer {
player_id: number;
x: number; // metres from the centre spot
y: number;
is_detected: boolean; // false = extrapolated, not seen
pitchX: number; // corner-origin metres, if your chart wants them
pitchY: number;
}Frames before kickoff carry nulls throughout with an empty player_data. That's the file's own
shape, not a parse failure — skip them rather than treating them as an error.
is_detected is the field to respect
The broadcast camera only frames part of the pitch. Players outside the shot can't be seen, so
SkillCorner estimates where they are — the file is called tracking_extrapolated for a
reason. Measured across 120 in-play frames of one match: 55% of positions were genuinely
detected, and no frame had all 22 players visible — the best had 19, the worst 8.
That matters downstream. Distance covered, whether a player was onside, the shape of a Voronoi cell for someone off-camera — all inherit the estimate. The example above fades undetected markers rather than drawing them identically.
It's the opposite choice to StatsBomb 360, which lists only players
inside the camera's visible_area and omits the rest. SkillCorner fills the gaps and flags
them; StatsBomb leaves the gaps. Neither is wrong — but you need to know which you're holding.
Joining a position to a player
Tracking carries only player_id — no name, no team. The match file is the lookup:
import { indexPlayersById } from "@pitchkit/data-providers/skillcorner";
const players = indexPlayersById(match);
const player = players.get(tracked.player_id); // name, team_id, shirt number, roleThe join key is players[].id, not trackable_object. They're different id spaces, and
trackable_object matches nothing in the tracking file — an easy hour to lose.
Which way is the team attacking?
Tracking coordinates are absolute, so a team's x flips sign at half time. This is the opposite of the dynamic events file, whose x is normalised to the attacking direction. Mixing them up mirrors half a match silently.
attackingSideOf resolves it from the match's own home_team_side:
import { attackingSideOf } from "@pitchkit/data-providers/skillcorner";
attackingSideOf(match, teamId, period); // "left_to_right" | "right_to_left" | undefinedOfficial documentation
This page describes how PitchKit loads the data. For the data itself, SkillCorner's own documentation is the authority:
- SkillCorner Open Data docs — the tracking format, coordinate system and field definitions, first-hand.
- SkillCorner/opendata — the repository these
loaders fetch from, including the Jupyter tutorials in
notebooks/tutorials. - skillcorner.com — the company behind the data.
The open data is MIT-licensed and SkillCorner ask to be credited in anything you publish from it.
Next
Dynamic events — SkillCorner's derived model of possessions, passing options, off-ball runs and pressure, sharing this file's frame counter.