PitchKit
DataSkillCorner

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

In 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 it

Coordinates 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.

On this page