# Phases of Play

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

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

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

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

```ts
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](/docs/data/skillcorner/tracking) uses and the same one
[dynamic events](/docs/data/skillcorner/dynamic-events) carry. So going from "this possession
led to a shot" to "show me it" needs no timestamp matching:

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

<Callout type="warn">
  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.
</Callout>

## Official documentation

Phase types are SkillCorner's taxonomy, and their documentation defines them:

* **[SkillCorner Open Data docs](https://skillcorner.github.io/opendata/)** — the phases-of-play
  file, what each attacking and defending phase type means, and a link to the full CSV
  specification PDF.
* **[SkillCorner/opendata](https://github.com/SkillCorner/opendata)** — the repository and its
  [tutorials](https://github.com/SkillCorner/opendata/tree/master/notebooks/tutorials).

MIT-licensed, and SkillCorner ask to be credited in anything you publish.
