@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-providersOne 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 trackedNot 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
outcomeat all. StatsBomb encodes success as the absence ofpass.outcome, not as a value — testing foroutcome.name === "Complete"finds nothing. That's whatisCompleteis 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.
endZis optional, even on shots: StatsBomb writes a two-elementend_locationfor a shot that never left the ground.- Some events have no location — Starting XI, Half Start, Substitution and
friends — so
x/yare 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/pitchYusing that match's own dimensions, leavingx/yexactly 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
xis 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". PreferstreamTracking(an async generator;breakaborts the download) orfetchTrackingWindow(an HTTPRangeread) overfetchTracking. - Roughly a third of positions are extrapolated, not seen. Broadcast
tracking only covers what the camera framed;
is_detectedsays 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/endYon events, andballandplayerson a frame.NaNcells becomenull(and no liftedx). - Events and tracking share a clock. An event's
"Start Frame"is a trackingFrame, and itsFromandToare a tracked player'splayer. - Coordinates are
0..1and 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 OUTqualifiedGOALby the player who put it in (isOwnGoal), soshots(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(HTTPRangereads, about 220 KB for a goal) orstreamTracking(an async generator;breakcancels both downloads) overfetchTracking. The ball isnullin 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 scoreImpect'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.coordinatesare absolute and swap ends at half time.start.adjCoordinatesturn every event so the acting team attacks towards+x; those are whatx/y/endX/endYlift. - The second half's clock starts at 10,000 seconds (
gameTimeInSec). UsematchSecondsfor a match clock andinMatchOrderto sort. - Set pieces aren't
PASSevents. A corner, free kick or throw-in is its own action type with apassobject, sopasses(events)leaves them out;events.filter(hasPass)is every ball played. - A goal is a successful
SHOTplus aGOALmarker, and an own goal is anOWN_GOALevent credited to the side that conceded.scorecounts 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