Metrica tracking and events
Metrica Sports' sample games — 25 fps tracking and the events on the same clock, typed and plotted.
Everything below is live: the example reads Metrica's sample data in your browser, picks out every goal from the events file, and replays the five seconds before it from the tracking data. Only those frames are downloaded, not the two 32 MB files they come from.
Reading the frames around the goal…
"use client";import { useEffect, useState } from "react";import { Pitch, Scatter, Voronoi } from "@pitchkit/react";import { fetchEvents, fetchTrackingWindow, isGoal, isOwnGoal, shots,} from "@pitchkit/data-providers/metrica";import type { MetricaEvent, MetricaFrame } from "@pitchkit/data-providers/metrica";import { docsAppearance } from "./docs-appearance";/** Metrica track at 25 fps, so this plays back in real time. */const FPS = 25;/** Five seconds of build-up before the goal, and one after it goes in. */const BEFORE = 5 * FPS;const AFTER = FPS;const selectClass = "w-full min-w-0 rounded-md border border-fd-border bg-fd-card py-1.5 pl-2 pr-8 text-sm disabled:opacity-50 sm:w-auto sm:max-w-xs";/** * Every goal in a game, own goals included. Metrica record an own goal as a * `BALL OUT` by the player who put it in, so `shots(...).filter(isGoal)` alone * would miss Sample Game 1's only away goal. */async function loadGoals(game: number, signal: AbortSignal): Promise<MetricaEvent[]> { const events = await fetchEvents(game, { signal }); return [...shots(events).filter(isGoal), ...events.filter(isOwnGoal)].sort( (a, b) => a["Start Frame"] - b["Start Frame"], );}/** * The frames around one goal. Events and tracking share a clock, so the * goal's own `Start Frame` is the window's anchor. `fetchTrackingWindow` * reads only those rows from each team's 32 MB file, with `Range` requests. */async function loadClip(game: number, goal: MetricaEvent, signal: AbortSignal) { return fetchTrackingWindow(game, { fromFrame: goal["Start Frame"] - BEFORE, toFrame: goal["End Frame"] + AFTER, signal, });}function goalLabel(goal: MetricaEvent): string { const seconds = Math.floor(goal["Start Time [s]"]); const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; // An own goal's Team is the side that conceded it. return isOwnGoal(goal) ? `${clock} · ${goal.Team} own goal (${goal.From})` : `${clock} · ${goal.Team} goal (${goal.From})`;}/** Advances a frame index at the data's own 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));}/** * Every goal in Metrica's two sample games, replayed from the tracking data * at 25 fps: each team's players, the ball, and the scorer ringed in white. * * Coordinates go in **raw**. `<Pitch type="metrica">` is Metrica's own `0..1` * grid, origin top-left with y downward, so the accessors are just `(p) => p.x`. */export function MetricaTrackingBasic() { const [game, setGame] = useState(1); const [loaded, setLoaded] = useState<{ game: number; goals: MetricaEvent[] }>(); const [goalIndex, setGoalIndex] = useState(0); const [clip, setClip] = useState<{ goal: MetricaEvent; frames: MetricaFrame[] }>(); // Which load failed, so picking another game or goal clears the message. const [failedLoad, setFailedLoad] = useState<string>(); useEffect(() => { const controller = new AbortController(); loadGoals(game, controller.signal) .then((goals) => { setLoaded({ game, goals }); setFailedLoad(undefined); }) .catch(() => !controller.signal.aborted && setFailedLoad(`events-${game}`)); return () => controller.abort(); }, [game]); const goals = loaded?.game === game ? loaded.goals : []; const goal = goals[goalIndex]; useEffect(() => { if (!goal) return; const controller = new AbortController(); loadClip(game, goal, controller.signal) .then((frames) => { setClip({ goal, frames }); setFailedLoad(undefined); }) .catch( () => !controller.signal.aborted && setFailedLoad(`clip-${game}-${goal["Start Frame"]}`), ); return () => controller.abort(); }, [game, goal]); const frames = clip && clip.goal === goal ? clip.frames : []; const failed = failedLoad === `events-${game}` || (goal !== undefined && failedLoad === `clip-${game}-${goal["Start Frame"]}`); const frame = frames[usePlayhead(frames.length)]; const fill = (p: { team: string }) => p.team === "Home" ? "var(--pitch-marker-primary)" : "var(--pitch-marker-goal)"; return ( <div> <div className="flex flex-col gap-2 sm:flex-row"> <select aria-label="Metrica sample game" value={game} onChange={(event) => { setGame(Number(event.target.value)); setGoalIndex(0); }} className={selectClass} > <option value={1}>Sample Game 1</option> <option value={2}>Sample Game 2</option> </select> <select aria-label="Goal" value={goalIndex} disabled={goals.length === 0} onChange={(event) => setGoalIndex(Number(event.target.value))} className={selectClass} > {goals.length === 0 && <option>Loading events…</option>} {goals.map((g, i) => ( <option key={g["Start Frame"]} value={i}> {goalLabel(g)} </option> ))} </select> </div> <p className="my-3 text-xs text-fd-muted-foreground"> {failed ? "Couldn't reach Metrica's sample data." : frame ? `${frames.length} frames · 105×68 m pitch · playing at ${FPS} fps` : "Reading the frames around the goal…"} </p> <Pitch type="metrica" appearance={docsAppearance}> {frame && ( <> <Voronoi data={frame.players} x={(p) => p.x} y={(p) => p.y} fill={fill} fillOpacity={0.13} stroke="rgba(255,255,255,0.18)" strokeWidth={0.4} /> <Scatter data={frame.players} x={(p) => p.x} y={(p) => p.y} // The event's `From` is the tracking column's own spelling, so // this is how the scorer is picked out. r={(p) => (p.player === goal?.From ? 3.6 : 2.4)} fill={fill} stroke={(p) => (p.player === goal?.From ? "#fff" : fill(p))} strokeWidth={(p) => (p.player === goal?.From ? 1.2 : 0.7)} tooltip={(p) => `${p.team} #${p.jersey ?? "?"}`} /> {/* Untracked in about 40% of frames, mostly while play is stopped. */} {frame.ball && ( <Scatter data={[frame.ball]} x={(b) => b.x} y={(b) => b.y} r={1.4} fill="#fff" stroke="#111" strokeWidth={0.4} /> )} </> )} </Pitch> </div> );}Two games, two kinds of file
Metrica publish two anonymised games in CSV: one events file per game, and one tracking file
per team. Teams are only ever "Home" and "Away", players "Player1" to "Player28".
import { fetchEvents, fetchTrackingWindow, streamTracking } from "@pitchkit/data-providers/metrica";
const events = await fetchEvents(1); // ~150 KB, under 2,000 rows
const frames = await fetchTrackingWindow(1, { fromFrame: 2164, toFrame: 2309 });METRICA_SAMPLE_GAMES is [1, 2].
Sample Game 3 is in a different format, FIFA EPTS tracking with XML metadata and JSON events, and these loaders don't read it.
Events and tracking share a clock
This is what makes the data worth loading together. An event's "Start Frame" is a tracking
Frame, and its From and To are the tracking file's own column headers, so placing the
player behind an event takes no mapping:
import { findPlayer, isGoal, shots } from "@pitchkit/data-providers/metrica";
const goal = shots(events).find(isGoal)!;
const atShot = frames.find((frame) => frame.Frame === goal["Start Frame"])!;
findPlayer(atShot, goal.From); // { team: "Home", player: "Player9", jersey: 9, x: 0.918, y: 0.467 }Measured across both games, a pass's start position sits a median 0.004 grid units from the passer's tracked position on its start frame.
Field names are Metrica's headers
Rows keep Metrica's CSV headers as their keys, spaces and units included, so their event definitions read against the types with no mapping table. What the package adds is lowercase:
goal["Start Frame"]; // 2289
goal.Subtype; // "HEAD-ON TARGET-GOAL"
goal.x; // 0.92, lifted from "Start X" for an accessor
frame["Time [s]"]; // 91.56
frame.ball; // { x, y } or null
frame.players; // [{ team, player, jersey, x, y }, …]A NaN cell becomes null, and a lifted x/y is absent rather than NaN. Every
SET PIECE and CARD lacks a start position; recoveries and challenges lack an end.
Player names are kept exactly as the header spells them. Sample Game 2 has a "Player 26", with a
space, in both its tracking and events files, so a match on From still works.
Coordinates are absolute
The grid is 0..1 on both axes, origin top-left with y increasing downward, on a 105×68 m
pitch. <Pitch type="metrica"> plots it raw.
Teams swap ends at half time, and which end each side starts at differs between the games:
Home attack towards x = 1 in Game 1's first half and towards x = 0 in Game 2's. Neither
file says so, so attackingDirection reads it from where the players stand:
import { attackingDirection } from "@pitchkit/data-providers/metrica";
const [kickOff] = await fetchTrackingWindow(1, { fromFrame: 1, toFrame: 1 });
attackingDirection(kickOff, "Home"); // "left_to_right"Pass a kick-off frame, where every player is in their own half. Mid-play the answer can be wrong: 39 frames before Game 1's opening goal, Home are camped in the Away half.
Qualifiers live in Subtype
Everything beyond the event type is a hyphen-joined list of qualifiers. hasSubtype reads one,
and the named predicates are built on it:
import { hasSubtype, isCross, isOnTarget, wonChallenge } from "@pitchkit/data-providers/metrica";
hasSubtype(goal, "HEAD"); // true
hasSubtype(goalKick, "GOAL"); // false: "GOAL KICK" is one qualifierA PASS always reached a teammate; one that didn't is a BALL LOST or BALL OUT. A goal
kick is a PASS qualified GOAL KICK (isGoalKick), never a SET PIECE.
An own goal is not a shot
Metrica record an own goal as a BALL OUT qualified GOAL, by the player who put it in, so
its Team is the side that conceded. shots(events).filter(isGoal) therefore misses Game 1's
only away goal. The example above adds events.filter(isOwnGoal):
const goals = [...shots(events).filter(isGoal), ...events.filter(isOwnGoal)];Reading tracking without downloading it
A game's tracking is two files, one per team, of about 32 MB and 140,000-odd frames each. Three ways in:
fetchTrackingWindow(game, { fromFrame, toFrame })reads just those rows with HTTPRangerequests. A goal's build-up costs about six requests and 220 KB, against 65 MB for the game.streamTracking(game)is an async generator over both files at once. Leaving the loop cancels both downloads.fetchTracking(game)reads both files whole. That suits a server; in a browser it is 65 MB.
frame.ball is null in about 40% of frames, mostly while play is stopped. A player who isn't
on the pitch (a substitute yet to come on) is left out of frame.players.
If the files are on disk, parseEvents(csv) and parseTracking(homeCsv, awayCsv) do the same
work with no network.
Official documentation
- metrica-sports/sample-data — the repository this package reads from.
- Event definitions
— every event type and subtype, which is what
hasSubtypeand the predicates read.
The repository has no licence file. Its README asks that anything public made from the data acknowledges Metrica Sports as the source.