Phases of Play
A match as a sequence of possessions — where each one started, where it got to, and whether it produced a shot.
Phases of play describe a match at the level above individual actions: each stretch with the ball in play and one team in possession, classified by what both teams were doing. It's the smallest of SkillCorner's three files at around 110 KB, and often the fastest way into a match.
Fetching phases of play (~110 KB)…
"use client";import { useEffect, useMemo, useState } from "react";import type { ReactNode } from "react";import { Arrows, Pitch } from "@pitchkit/react";import { fetchMatch, fetchMatches, fetchPhasesOfPlay, phaseLedToShot,} from "@pitchkit/data-providers/skillcorner";import type { SkillCornerMatch, SkillCornerMatchSummary, SkillCornerPhase,} from "@pitchkit/data-providers/skillcorner";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, matchLabel, selectClass } from "./skillcorner-live";const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const;interface Loaded { readonly match: SkillCornerMatch; readonly phases: readonly SkillCornerPhase[];}/** * Load a match's phases of play — the smallest of SkillCorner's three files * at around 110 KB, and the one that describes the match as a sequence of * possessions rather than as individual actions. */async function loadPhases(matchId: number, signal: AbortSignal): Promise<Loaded> { const match = await fetchMatch(matchId, { signal }); const phases = await fetchPhasesOfPlay(match, { signal }); // Only phases that travelled somewhere can be drawn as an arrow. return { match, phases: phases.filter( (phase) => typeof phase.x_start === "number" && typeof phase.x_end === "number", ), };}function usePhases(matchId: number) { const [loaded, setLoaded] = useState<{ key: number; value: Loaded } | undefined>(); const [failed, setFailed] = useState(false); useEffect(() => { const controller = new AbortController(); loadPhases(matchId, controller.signal) .then((value) => setLoaded({ key: matchId, value })) .catch(() => { if (!controller.signal.aborted) setFailed(true); }); return () => controller.abort(); }, [matchId]); return { loaded: loaded?.key === matchId ? loaded.value : undefined, failed };}/** Match picker, any extra controls, and the status line. */function MatchPicker({ value, onChange, status, children,}: { value: number; onChange: (id: number) => void; status: ReactNode; children?: ReactNode;}) { const [matches, setMatches] = useState<SkillCornerMatchSummary[]>([]); useEffect(() => { fetchMatches() .then(setMatches) .catch(() => undefined); }, []); return ( <> <div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <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> {children} </div> <p className="my-3 text-xs text-fd-muted-foreground">{status}</p> </> );}/** * Phases of play from a real SkillCorner match: one arrow per possession, * from where it started to where it got to. The ones that produced a shot * are what you're looking for, so they're the ones picked out. */export function SkillcornerPhasesBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); const [phaseType, setPhaseType] = useState("all"); const { loaded, failed } = usePhases(matchId); const phaseTypes = useMemo(() => { const seen = new Set<string>(); for (const phase of loaded?.phases ?? []) { if (phase.team_in_possession_phase_type) seen.add(phase.team_in_possession_phase_type); } return [...seen].sort(); }, [loaded]); const phases = (loaded?.phases ?? []).filter( (phase) => phaseType === "all" || phase.team_in_possession_phase_type === phaseType, ); return ( <div> <MatchPicker value={matchId} onChange={setMatchId} status={ failed ? "Couldn't reach SkillCorner open data." : loaded === undefined ? "Fetching phases of play (~110 KB)…" : `${phases.length} phases · ${phases.filter(phaseLedToShot).length} led to a shot (highlighted)` } > {phaseTypes.length > 0 && ( <select aria-label="Phase type" value={phaseType} onChange={(event) => setPhaseType(event.target.value)} className={selectClass} > <option value="all">All phase types</option> {phaseTypes.map((type) => ( <option key={type} value={type}> {type.replace(/_/g, " ")} </option> ))} </select> )} </MatchPicker> <Pitch type="skillcorner" dimensions={ loaded && { length: loaded.match.pitch_length, width: loaded.match.pitch_width } } appearance={docsAppearance} > <Arrows data={phases} x={(phase) => phase.x_start ?? 0} y={(phase) => phase.y_start ?? 0} x2={(phase) => phase.x_end ?? 0} y2={(phase) => phase.y_end ?? 0} stroke={(phase: SkillCornerPhase) => phaseLedToShot(phase) ? TEAM_COLORS[1] : TEAM_COLORS[0] } strokeOpacity={(phase: SkillCornerPhase) => (phaseLedToShot(phase) ? 0.9 : 0.22)} strokeWidth={(phase: SkillCornerPhase) => (phaseLedToShot(phase) ? 0.7 : 0.3)} headSize={4} tooltip={(phase) => `${phase.team_in_possession_shortname ?? "?"} — ${ phase.team_in_possession_phase_type?.replace(/_/g, " ") ?? "phase" }${phaseLedToShot(phase) ? " → shot" : ""}` } /> </Pitch> </div> );}Each arrow is one possession, start to end. The highlighted ones produced a shot.
Loading them
import { fetchMatch, fetchPhasesOfPlay } from "@pitchkit/data-providers/skillcorner";
const match = await fetchMatch(1874553);
const phases = await fetchPhasesOfPlay(match);Like the other loaders it takes the match, since coordinates are metres from the centre spot. One match has roughly 400 phases.
What a phase carries
interface SkillCornerPhase {
index: number;
frame_start: number; // a tracking frame number
frame_end: number;
period: number | null;
minute_start: number | null;
team_in_possession_id: number | null;
team_in_possession_shortname: string | null;
team_in_possession_phase_type: string | null; // build_up, counter, …
team_out_of_possession_phase_type: string | null; // high_press, mid_block, low_block, …
team_possession_lead_to_shot: boolean | null;
team_possession_lead_to_goal: boolean | null;
x_start: number | null; // normalised to the attacking direction
y_start: number | null;
x_end: number | null;
y_end: number | null;
team_in_possession_width_start: number | null; // how spread the team was, in metres
team_in_possession_length_start: number | null;
// …and the rest of the 44 columns, under their own names
}Two things here are hard to get anywhere else in open data. Both teams are classified at once — a phase says what the team in possession was doing and what the team out of possession was doing, so "build-up against a high press" is a filter rather than a judgement call. And team width and length are given directly in metres, which otherwise means deriving shape from tracking yourself.
Filtering
import { isPhaseType, phaseLedToShot, phaseLedToGoal } from "@pitchkit/data-providers/skillcorner";
const counters = phases.filter(isPhaseType("counter"));
const dangerous = phases.filter(phaseLedToShot);
const goals = phases.filter(phaseLedToGoal);isPhaseType is a factory — it returns the predicate — so it composes with .filter() the
same way the fixed ones do.
Lining phases up with the other files
frame_start and frame_end are tracking frame numbers, the same counter the
tracking file uses and the same one
dynamic events carry. So going from "this possession
led to a shot" to "show me it" needs no timestamp matching:
const phase = phases.filter(phaseLedToShot)[0];
// The tracking frames for exactly that possession
const frames = await fetchTrackingWindow(match, {
fromFrame: phase.frame_start,
toFrame: phase.frame_end,
});
// The dynamic events inside it
const inPhase = events.filter(
(event) => event.frame_start >= phase.frame_start && event.frame_start <= phase.frame_end,
);That shared counter is the most useful property of this dataset, and the reason the three files are worth treating as one thing rather than three.
Phase coordinates follow the dynamic-events convention, not tracking's: x is normalised so
positive always points at the goal being attacked. Don't overlay them on absolute tracking
positions without converting.
Official documentation
Phase types are SkillCorner's taxonomy, and their documentation defines them:
- SkillCorner Open Data docs — the phases-of-play file, what each attacking and defending phase type means, and a link to the full CSV specification PDF.
- SkillCorner/opendata — the repository and its tutorials.
MIT-licensed, and SkillCorner ask to be credited in anything you publish.