PitchKit
DataSkillCorner

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, role

The 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" | undefined

Official documentation

This page describes how PitchKit loads the data. For the data itself, SkillCorner's own documentation is the authority:

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.

On this page