PitchKit
API Reference

@pitchkit/data-providers

Convenience loaders for open football data, shaped for PitchKit. Raw provider JSON in, typed events out — with the coordinates already where a <Scatter> accessor wants them.

Five provider modules: StatsBomb (events + 360 tracking), SkillCorner (broadcast tracking, dynamic events, phases of play), Wyscout (events), Metrica Sports (synchronised tracking and events) and Impect (events), each on its own import subpath so you only pull in what you use.

npm install @pitchkit/data-providers

One runtime dependency (csv-parse, for SkillCorner's and Metrica's CSV files), and no dependency on @pitchkit/core or @pitchkit/react — it's pure data transformation, useful on its own.

A shot map, end to end

import { fetchMatchEvents, shots, isGoal } from "@pitchkit/data-providers/statsbomb";
import { Scatter, VerticalPitch } from "@pitchkit/react";

const events = await fetchMatchEvents(15946);
const barcelona = shots(events).filter((shot) => shot.team.name === "Barcelona");

<VerticalPitch type="statsbomb">
  <Scatter
    data={barcelona}
    x={(shot) => shot.x}
    y={(shot) => shot.y}
    r={(shot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 11}
    fill={(shot) => (isGoal(shot) ? "orange" : "steelblue")}
  />
</VerticalPitch>;

What this is (and isn't)

A convenience package, for tutorials, prototypes, learning and agent-assisted builds — getting from "a match id" to "a chart" without a data pipeline in between.

It is not a production data layer. If you already have your own pipeline, you don't need this: PitchKit's marks take accessor functions, so they read whatever shape your data is already in. Nothing here is required to use PitchKit.

Design

StatsBomb's data stays StatsBomb's. Fields keep their names and their snake_case spelling; values keep StatsBomb's own strings. An outcome is "Off T", not "off-target". You can read StatsBomb's spec and this package's types side by side with no mapping table in between, and nothing is paraphrased into a vocabulary you'd then have to learn.

Coordinates are the one exception. location and end_location arrays are also surfaced as x, y, endX, endY (and endZ for shots that left the ground), so they drop straight into an accessor. The original arrays are left untouched.

Interpretation lives in functions, not fields. There's no invented complete: boolean on a pass — there's an isComplete(pass) you apply. Same for isGoal, isCorner, isCross. The data stays a faithful record; the reading of it is opt-in and inspectable.

Nothing is dropped. Only shots, passes and carries are typed explicitly. Every other event type comes back as a StatsBombGenericEvent with its own sub-object intact — reach it with ofType(events, "Duel"). There is no raw field because the parsed event is the original, spread, plus coordinates.

Narrowing goes through the guards

StatsBomb's discriminant is type.name — nested one level down. TypeScript only narrows unions on top-level literal discriminants, so this compiles but does not narrow:

if (event.type.name === "Shot") {
  event.shot.statsbomb_xg; // ✗ Property 'shot' does not exist on type 'StatsBombEvent'
}

Use the guards instead — they narrow properly, and they check the sub-object is really present rather than trusting the name:

if (isShot(event)) {
  event.shot.statsbomb_xg; // ✓
}

Hoisting a top-level discriminant would fix this, but only by inventing a field StatsBomb doesn't have — which is exactly what this package avoids.

API

Import from @pitchkit/data-providers/statsbomb. Everything is tree-shakeable, and provider modules are separate entry points so you never pull in code for a provider you don't use.

Parse — pure, no network. parseEvents, parseCompetitions, parseMatches, parseLineups.

Load from a URL — a mirror, your own host, anything. loadEvents, loadCompetitions, loadMatches, loadLineups, loadThreeSixty.

Fetch from open data — builds the public URL for you. fetchMatchEvents(matchId), fetchCompetitions(), fetchMatches(competitionId, seasonId), fetchLineups(matchId), fetchMatchThreeSixty(matchId). Each takes optional { baseUrl, fetch, signal }, so you can point at a mirror or wrap the request (Next.js caching, a proxy, a test stub). Built on the global fetch — no HTTP client dependency.

Note that competitions.json rows are competition and season pairs, which is why fetchMatches needs both ids.

Select — shots, passes, carries, ofType, and the isShot / isPass / isCarry guards. For 360 data: indexThreeSixtyByEvent (the join), teammatesIn/opponentsIn/actorIn/keeperIn (filtering a frame's tracked players), and visibleAreaPolygon (the camera-coverage polygon as point pairs for a Polygon layer).

Predicates — isComplete, isCorner, isFreeKick, isThrowIn, isCross, isThroughBall, isSwitch, isAssist, isKeyPass, isSetPiece, isGoal, isPenalty, isOnTarget. For 360 tracked players: isTeammate, isOpponent, isActor, isKeeper.

360 tracking data

import {
  fetchMatchEvents,
  fetchMatchThreeSixty,
  indexThreeSixtyByEvent,
  isShot,
} from "@pitchkit/data-providers/statsbomb";

const [events, frames] = await Promise.all([
  fetchMatchEvents(3857276),
  fetchMatchThreeSixty(3857276),
]);
const frameByEvent = indexThreeSixtyByEvent(frames);

const shot = events.find(isShot);
const frame = shot && frameByEvent.get(shot.id); // undefined if this event wasn't tracked

Not every match has 360 coverage, and not every event within a covered match has a frame — coverage varies by match (85% in one sampled World Cup match) since 360 only runs on events the camera could see. Check match.match_status_360 === "available" before fetching, and don't assume frameByEvent.get(event.id) will hit even within a covered match.

A tracked player carries no identity — { teammate, actor, keeper, location } only, relative to the event's own team — which is why the selectors above read like predicates rather than lookups.

Gotchas worth knowing

  • A completed pass has no outcome at all. StatsBomb encodes success as the absence of pass.outcome, not as a value — testing for outcome.name === "Complete" finds nothing. That's what isComplete is for.
  • Events files are large; 360 files are larger. A match's events are roughly 3 MB, its 360 tracking 5-7 MB — fetch once and cache either.
  • endZ is optional, even on shots: StatsBomb writes a two-element end_location for a shot that never left the ground.
  • Some events have no location — Starting XI, Half Start, Substitution and friends — so x/y are optional on the base event. They're required on shots, passes and carries, which always have one.

SkillCorner

import {
  fetchMatch,
  fetchDynamicEvents,
  streamTracking,
  offBallRuns,
} from "@pitchkit/data-providers/skillcorner";

const match = await fetchMatch(1874553);
const runs = offBallRuns(await fetchDynamicEvents(match));

20 A-League 2024/25 matches of broadcast tracking, plus SkillCorner's derived dynamic events and phases of play. Same four layers as above, with four differences that matter before you plot anything:

  • Coordinates are metres from the centre spot, on a pitch whose real dimensions vary by match (104, 105 and 106 m all appear in the dataset). The parsers add corner-origin pitchX/pitchY using that match's own dimensions, leaving x/y exactly as SkillCorner wrote them — which is why the loaders take the match object, not just its id. They stay in that match's real metres; mapping onto a fixed pitch type is your call.
  • The two files disagree about direction. Tracking positions are absolute and swap ends at half time; dynamic-event x is normalised so positive always points at the goal being attacked. Mixing them up mirrors half a match silently. attackingSideOf(match, teamId, period) resolves the former.
  • Tracking files are ~90 MB and stored in Git LFS, so they come from media.githubusercontent.com, not the raw host — fetching the raw URL returns a 130-byte pointer stub that fails as "not valid JSON". Prefer streamTracking (an async generator; break aborts the download) or fetchTrackingWindow (an HTTP Range read) over fetchTracking.
  • Roughly a third of positions are extrapolated, not seen. Broadcast tracking only covers what the camera framed; is_detected says which is which, and it's worth surfacing rather than hiding.

The dynamic-events CSV has 322 columns. The ~40 you plot or filter on are typed; the rest stay reachable under their original names through an index signature.

Selectors: playerPossessions, passingOptions, offBallRuns, onBallEngagements, ofEventType. Tracking joins onto the match through indexPlayersById — on players[].id, not trackable_object.

Metrica

import {
  fetchEvents,
  fetchTrackingWindow,
  findPlayer,
  isGoal,
  shots,
} from "@pitchkit/data-providers/metrica";

const goal = shots(await fetchEvents(1)).find(isGoal)!;
const frames = await fetchTrackingWindow(1, {
  fromFrame: goal["Start Frame"] - 125, // five seconds of build-up
  toFrame: goal["End Frame"],
});
const atShot = frames.find((frame) => frame.Frame === goal["Start Frame"])!;
const scorer = findPlayer(atShot, goal.From); // { team: "Home", player: "Player9", x, y, … }

Metrica's two anonymised CSV sample games: tracking at 25 fps for every player and the ball, and the events that go with it. Plot both on <Pitch type="metrica">. What to know first:

  • Field names are Metrica's CSV headers, spaces and units included: event["Start Frame"], event.From, frame["Time [s]"]. Everything the package adds is lowercase: x/y/endX/endY on events, and ball and players on a frame. NaN cells become null (and no lifted x).
  • Events and tracking share a clock. An event's "Start Frame" is a tracking Frame, and its From and To are a tracked player's player.
  • Coordinates are 0..1 and absolute: origin top-left, y downward, and teams swap ends at half time. Which end each side starts at differs between the games; attackingDirection(frame, team) reads it from a kick-off frame.
  • An own goal is not a shot. Metrica record it as a BALL OUT qualified GOAL by the player who put it in (isOwnGoal), so shots(events).filter(isGoal) misses Sample Game 1's only away goal.
  • Tracking is two files of about 32 MB a game, one per team. Prefer fetchTrackingWindow (HTTP Range reads, about 220 KB for a goal) or streamTracking (an async generator; break cancels both downloads) over fetchTracking. The ball is null in about 40% of frames.
  • Sample Game 3 isn't supported. It is in a different format (FIFA EPTS tracking, JSON events).

Qualifiers live in Subtype, hyphen-joined ("HEAD-ON TARGET-GOAL"): hasSubtype reads one, and isOnTarget, wonChallenge, isCross, isGoalKick and the rest are built on it.

Impect

import {
  fetchMatches,
  fetchMatchEvents,
  IMPECT_BUNDESLIGA_2023_24,
  isGoal,
  score,
  shots,
} from "@pitchkit/data-providers/impect";

const matches = await fetchMatches(IMPECT_BUNDESLIGA_2023_24); // all 306
const events = await fetchMatchEvents(122838); // Werder Bremen 0-4 Bayern
const goals = shots(events).filter(isGoal);
const match = matches.find((m) => m.id === 122838)!;
score(events, match); // { home: 0, away: 4 }; the match list has no score

Impect's event data for the whole 2023/24 Bundesliga, with lineups, squads and players. Plot it on <Pitch type="impect">: metres from the centre spot, y to the left. What to know first:

  • It is not openly licensed. Impect's terms allow non-commercial analytical and research use, forbid redistributing or altering the data, and require Impect to be credited, with their logo, in anything published. Read the terms before you build on it.
  • Two coordinate frames. start.coordinates are absolute and swap ends at half time. start.adjCoordinates turn every event so the acting team attacks towards +x; those are what x/y/endX/endY lift.
  • The second half's clock starts at 10,000 seconds (gameTimeInSec). Use matchSeconds for a match clock and inMatchOrder to sort.
  • Set pieces aren't PASS events. A corner, free kick or throw-in is its own action type with a pass object, so passes(events) leaves them out; events.filter(hasPass) is every ball played.
  • A goal is a successful SHOT plus a GOAL marker, and an own goal is an OWN_GOAL event credited to the side that conceded. score counts both.
  • A match's events are 5-6 MB. Fetch once and keep them.

Files you already have

Every provider module splits the network layer in two: loadX(url) takes any URL — a mirror, your own bucket, a static server — and fetchX(id) is sugar that builds the open-data URL for you.

If the files are on disk, skip both and use the parse* layer, which is pure:

import { readFileSync, createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import {
  parseMatch,
  parseDynamicEvents,
  parseTrackingFrame,
  pitchProjection,
} from "@pitchkit/data-providers/skillcorner";

// The match file first — everything else needs its pitch dimensions.
const match = parseMatch(JSON.parse(readFileSync("1874553_match.json", "utf8")));
const events = parseDynamicEvents(readFileSync("1874553_dynamic_events.csv", "utf8"), match);

Tracking files are far too big to read into a string, so parse them a line at a time — which is exactly what parseTrackingFrame and pitchProjection are exported for:

const project = pitchProjection(match);
const lines = createInterface({
  input: createReadStream("1874553_tracking_extrapolated.jsonl"),
  crlfDelay: Infinity,
});

for await (const line of lines) {
  if (!line.trim()) continue;
  const frame = parseTrackingFrame(JSON.parse(line), project);
  // …one frame at a time, constant memory
}

Data licence and attribution

This package ships no data. It fetches from whatever URL you give it.

The default URLs point at each provider's own open-data repository (Wyscout's at a per-match mirror). All of them ask to be credited in anything you publish from their data.

StatsBomb — open-data · specifications · free data hub · usage terms. Released under StatsBomb's own user agreement rather than an OSI licence, so read the terms before you rely on it.

SkillCorner — opendata · documentation · tutorials · skillcorner.com. MIT-licensed, with a request for credit.

Wyscout — the Pappalardo et al. dataset on figshare, CC BY 4.0. Events are fetched from the koenvo/wyscout-soccer-match-event-dataset mirror, which splits the official archive per match without renaming a field.

Metrica Sports — sample-data · event definitions. No licence file; the README asks that anything public made from the data acknowledges the source.

Impect — open-data · documentation · terms of use. Not an open licence: non-commercial analytical and research use only, no redistribution or alteration of the data, and credit to Impect, with their logo, in anything published.

Each provider's documentation is the authority on what the fields mean; this package only covers loading them.

Adding a provider

Providers live one per directory under src/, each with its own entry in package.json's exports and tsup.config.ts's entry. Follow src/statsbomb/ — types, parse, load, select, predicates — and keep the same principle: the provider's own vocabulary in the data, interpretation in the functions. Test against trimmed real files in __fixtures__/, unless the provider's terms forbid copying their data, as Impect's do; then hand-make the fixtures in the provider's exact shapes and record how each fact they encode was measured.

Licence

MIT

Modules

On this page