Dynamic Events
Possessions, passing options, off-ball runs and pressure — SkillCorner's derived event model, 322 columns wide.
Dynamic events are SkillCorner's derived layer: what the tracking data implies about possessions, the options a player had, the runs made off the ball, and the pressure applied. The example plots off-ball runs as comets, from where each run began to where it ended.
Fetching dynamic events (~4 MB)…
"use client";import { useEffect, useState } from "react";import type { ReactNode } from "react";import { Comet, Pitch, Scatter } from "@pitchkit/react";import { fetchDynamicEvents, fetchMatch, fetchMatches, hasPath, isSprint, offBallRuns,} from "@pitchkit/data-providers/skillcorner";import type { SkillCornerMatch, SkillCornerMatchSummary, SkillCornerOffBallRun,} from "@pitchkit/data-providers/skillcorner";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, buttonClass, matchLabel, selectClass } from "./skillcorner-live";const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const;interface Loaded { readonly match: SkillCornerMatch; readonly runs: readonly SkillCornerOffBallRun[];}/** * Load a match's dynamic events and narrow them to off-ball runs. * * `fetchDynamicEvents` takes the *match*, not just its id, because the CSV's * coordinates are metres from the centre spot and placing them needs that * pitch's real dimensions. `offBallRuns` is the narrowing selector — the same * shape as StatsBomb's `shots()`/`passes()`. */async function loadRuns(matchId: number, signal: AbortSignal): Promise<Loaded> { const match = await fetchMatch(matchId, { signal }); const events = await fetchDynamicEvents(match, { signal }); // `hasPath` keeps only runs with both a start and an end, which is what a // <Comet> needs to draw. return { match, runs: offBallRuns(events).filter(hasPath) };}function useRuns(matchId: number) { const [loaded, setLoaded] = useState<{ key: number; value: Loaded } | undefined>(); const [failed, setFailed] = useState(false); useEffect(() => { const controller = new AbortController(); loadRuns(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> </> );}/** * Off-ball runs from a real SkillCorner match, drawn as comets that taper * from where the run began to where it ended. * * Event coordinates are normalised to the attacking direction, so every run * points the same way regardless of which half it happened in. */export function SkillcornerEventsBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); const [sprintsOnly, setSprintsOnly] = useState(false); const { loaded, failed } = useRuns(matchId); const runs = (loaded?.runs ?? []).filter((run) => !sprintsOnly || isSprint(run)); const color = (run: SkillCornerOffBallRun) => run.team_id === loaded?.match.home_team.id ? TEAM_COLORS[0] : TEAM_COLORS[1]; return ( <div> <MatchPicker value={matchId} onChange={setMatchId} status={ failed ? "Couldn't reach SkillCorner open data." : loaded === undefined ? "Fetching dynamic events (~4 MB)…" : `${runs.length} off-ball runs` } > <button type="button" className={buttonClass} disabled={loaded === undefined} onClick={() => setSprintsOnly((was) => !was)} > {sprintsOnly ? "All runs" : "Sprints only"} </button> </MatchPicker> <Pitch type="skillcorner" dimensions={ loaded && { length: loaded.match.pitch_length, width: loaded.match.pitch_width } } appearance={docsAppearance} > <Comet data={runs} x={(run) => run.x_start ?? 0} y={(run) => run.y_start ?? 0} x2={(run) => run.x_end ?? 0} y2={(run) => run.y_end ?? 0} color={color} startWidth={0.3} endWidth={1.4} gradient tooltip={(run) => `${run.player_name ?? "Unknown"} — ${run.event_subtype?.replace(/_/g, " ") ?? "run"}, ${ run.distance_covered?.toFixed(0) ?? "?" } m` } /> <Scatter data={runs} x={(run) => run.x_end ?? 0} y={(run) => run.y_end ?? 0} r={1.2} fill={color} fillOpacity={0.9} /> </Pitch> </div> );}Loading them
import { fetchMatch, fetchDynamicEvents } from "@pitchkit/data-providers/skillcorner";
const match = await fetchMatch(1874553);
const events = await fetchDynamicEvents(match);fetchDynamicEvents takes the match, not just its id, because the coordinates are metres
from the centre spot and placing them needs that pitch's real dimensions. The file is around
4 MB — fetch once and cache.
Already have the CSV on disk? parseDynamicEvents(text, match) is pure, no network. And
loadDynamicEvents(url, match) takes any URL if you keep a mirror.
Four event types
import {
playerPossessions,
passingOptions,
offBallRuns,
onBallEngagements,
ofEventType,
} from "@pitchkit/data-providers/skillcorner";
playerPossessions(events); // a player's time on the ball
passingOptions(events); // every team-mate who was available to receive
offBallRuns(events); // movement away from the ball
onBallEngagements(events); // pressure, presses, duelsIn one sampled match those split roughly 2,500 / 960 / 880 / 540 — so passing_option is
over half of everything, because SkillCorner emits one per available receiver per possession.
Plot them unfiltered and they bury the rest.
Unlike StatsBomb's, this discriminant is top level, so event.event_type === "off_ball_run"
narrows natively. The guards exist anyway, and also check the row carries what the type
implies.
Predicates
import {
breaksDefensiveLine,
isRunBehind,
isSprint,
wasReceived,
isCompletePass,
leadToShot,
hasPath,
} from "@pitchkit/data-providers/skillcorner";
offBallRuns(events).filter(isSprint).filter(breaksDefensiveLine);
passingOptions(events).filter(wasReceived);isSprint reads SkillCorner's own speed_avg_band, not a threshold invented here.
hasPath keeps only events with both a start and an end — which is what <Comet> and
<Arrows> need.
A completed pass is explicit here. pass_outcome === "successful", unlike StatsBomb where
success is the absence of pass.outcome. isCompletePass is a real equality check rather than
a workaround.
The 322 columns
The source CSV is 322 columns wide — expected possession value, line breaks, pressure bands, passing-option scoring, distances to the last defensive line, and much more. Typing all of them would be a worse lie than leaving them honest, so the roughly forty you plot or filter on are typed, and every other column stays reachable under its original name:
const run = offBallRuns(events)[0];
run.distance_covered; // typed
run.xthreat; // typed
run["affected_line_breaking_passing_option_xthreat"]; // still there, as the CSV wrote itCoordinates point at the goal being attacked
Dynamic-event x is normalised to the attacking direction: positive x always points at the
goal that team is attacking, in both halves. So a shot-ending phase is at high x whichever way
the team kicked.
This is the opposite of the tracking file, whose coordinates are absolute and swap ends at half time. Plot one with the other's assumption and you mirror half a match with no error to tell you. The two files share a frame counter but not a coordinate convention.
Verified against a full match: every wide_left and half_space_left row has y > 0 and every
*_right row y < 0, with no crossover — so y > 0 is the attacking team's left, which is
"up" on a y-up pitch. The parser also adds corner-origin pitchX/pitchY if your chart wants
them, but <Pitch type="skillcorner"> takes x_start/y_start directly.
Reading the fields
<Comet
data={offBallRuns(events).filter(hasPath)}
x={(run) => run.x_start}
y={(run) => run.y_start}
x2={(run) => run.x_end}
y2={(run) => run.y_end}
tooltip={(run) => `${run.player_name} — ${run.event_subtype}, ${run.distance_covered} m`}
/>Run subtypes are SkillCorner's own vocabulary — run_ahead_of_the_ball, coming_short,
dropping_off, support, cross_receiver, overlap — kept as they're spelled.
Official documentation
The 322 columns are SkillCorner's, and so is the vocabulary. Their documentation is the authority on what each one means:
- SkillCorner Open Data docs — the dynamic-event model, including the expected-possession-value and pressure sections, plus a link to their full CSV specification PDF.
- SkillCorner/opendata — the repository, with worked tutorials covering game intelligence, dynamic events and phases of play.
MIT-licensed, and SkillCorner ask to be credited in anything you publish.
Next
Phases of play — the same match described as a sequence of possessions rather than individual actions.