# Dynamic Events

Source: https://www.pitchkitjs.com/docs/data/skillcorner/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.

```tsx
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 { 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 }
        }
      >
        <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

```ts
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

```ts
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

```ts
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.

<Callout>
  **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.
</Callout>

## 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:

```ts
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.

<Callout type="warn">
  This is the **opposite** of the [tracking](/docs/data/skillcorner/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.
</Callout>

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

```tsx
<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](https://skillcorner.github.io/opendata/)** — the dynamic-event
  model, including the expected-possession-value and pressure sections, plus a link to their
  full CSV specification PDF.
* **[SkillCorner/opendata](https://github.com/SkillCorner/opendata)** — the repository, with
  worked [tutorials](https://github.com/SkillCorner/opendata/tree/master/notebooks/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](/docs/data/skillcorner/phases-of-play) — the same match described as a
sequence of possessions rather than individual actions.
