Wyscout events
Match events from the Wyscout open dataset — typed, filtered, plotted.
Everything below is live: the example fetches a real match from the Wyscout open-data mirror in your browser, and the match picker switches between five recognisable fixtures.
Fetching the match (~480 KB)…
"use client";import { useEffect, useState } from "react";import { cropForHalf, getPitchDimensions } from "@pitchkit/core";import { Scatter, VerticalPitch } from "@pitchkit/react";import { fetchMatch, goalAttempts, indexPlayersById, isGoal, isOwnGoal, shotGoalZone,} from "@pitchkit/data-providers/wyscout";import type { WyscoutEvent, WyscoutPlayer } from "@pitchkit/data-providers/wyscout";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, WYSCOUT_MATCHES, selectClass } from "./wyscout-live";const dimensions = getPitchDimensions("wyscout");/** The curated shortlist over a line of status text. */function MatchSelector({ value, onChange, status,}: { value: number; onChange: (matchId: number) => void; status: string;}) { return ( <> <select aria-label="Wyscout match" value={value} onChange={(event) => onChange(Number(event.target.value))} className={selectClass} > {WYSCOUT_MATCHES.map((match) => ( <option key={match.id} value={match.id}> {match.label} </option> ))} </select> <p className="my-3 text-xs text-fd-muted-foreground">{status}</p> </> );}interface Loaded { readonly attempts: readonly WyscoutEvent[]; readonly goals: number; readonly ownGoals: number; readonly players: Map<number, WyscoutPlayer>;}const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;/** * A shot map built from a real Wyscout match, fetched in the browser. * * `goalAttempts(events)` first, `.filter(isGoal)` after — in that order. * Wyscout tags a goal on the conceding keeper's save as well as on the * attempt that scored it, so filtering the *whole* feed for the goal tag * counts each one twice. `goalAttempts` rather than `shots`, because a * penalty or direct free kick is a `Free Kick` event, not a `Shot`. * * An own goal is neither: it carries its own tag, 102, on the event of the * player who put it in. Shootout kicks (`matchPeriod: "P"`) are attempts but not goals. */export function WyscoutEventsBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); // Keyed by the match it belongs to, so "still loading" is derived rather // than a second state field. const [result, setResult] = useState<{ key: number; value: Loaded } | undefined>(); const [failed, setFailed] = useState(false); const loaded = result?.key === matchId ? result.value : undefined; useEffect(() => { fetchMatch(matchId) .then((match) => { const attempts = goalAttempts(match.events); setResult({ key: matchId, value: { attempts, goals: attempts.filter((e) => isGoal(e) && e.matchPeriod !== "P").length, ownGoals: match.events.filter(isOwnGoal).length, players: indexPlayersById(match), }, }); }) .catch(() => setFailed(true)); }, [matchId]); return ( <div> <MatchSelector value={matchId} onChange={(next) => { setFailed(false); setMatchId(next); }} status={ failed ? "Couldn't reach Wyscout open data." : loaded === undefined ? "Fetching the match (~480 KB)…" : `${plural(loaded.attempts.length, "attempt")} · ${plural(loaded.goals + loaded.ownGoals, "goal")}` + (loaded.ownGoals > 0 ? ` (${plural(loaded.ownGoals, "own goal")})` : "") } /> <VerticalPitch type="wyscout" appearance={docsAppearance} crop={cropForHalf(dimensions)}> <Scatter data={loaded?.attempts ?? []} x={(shot) => shot.x} y={(shot) => shot.y} r={(shot) => (isGoal(shot) ? 6 : 4)} fill={(shot) => isGoal(shot) ? "var(--pitch-marker-goal)" : "var(--pitch-marker-primary)" } fillOpacity={(shot) => (isGoal(shot) ? 0.95 : 0.55)} stroke="white" strokeWidth={(shot) => (isGoal(shot) ? 2 : 1)} tooltip={(shot) => { const player = loaded?.players.get(shot.playerId)?.shortName ?? "Unknown"; // No end coordinate on a shot — where it went is a tag, read // through shotGoalZone, not positions[1]. See "Reading the // fields" below. Penalties carry the same goal-mouth tags. const outcome = isGoal(shot) ? "Goal" : (shotGoalZone(shot) ?? "Blocked"); return `${player} — ${outcome}`; }} /> </VerticalPitch> </div> );}Getting a match
import { fetchMatch } from "@pitchkit/data-providers/wyscout";
const match = await fetchMatch(2499943); // Liverpool 4–3 Manchester City, 2018
const events = match.events; // both squads travel with it, ready to label a tooltipfetchMatch returns the events and both teams' squads in one call — there's no second
request to turn a playerId into a name.
There's no fetchMatches here. The dataset's 1,941 matches have no published JSON index — only a
generated Markdown table — so this page's picker is a curated shortlist rather than a live search.
Any of the dataset's match ids works the same way with fetchMatch; a match file is roughly 480
KB.
If you already have the JSON, skip the network entirely with parseMatch(json), or point
loadMatch(url) at wherever you keep it.
Narrowing the feed
Wyscout's discriminant is top level — unlike StatsBomb's, which is nested inside type
— so a plain comparison narrows without a guard:
import { duels, goalAttempts, passes, shots } from "@pitchkit/data-providers/wyscout";
goalAttempts(events); // every Shot, penalty and direct free kick
shots(events); // every Shot
passes(events); // every Pass
duels(events); // every DuelA penalty, and a free kick struck straight at goal, are Free Kick events (subEventName
"Penalty" and "Free kick shot"), not Shots. goalAttempts adds them back, so use it rather
than shots for a shot map or a goal count.
ofType(events, "Free Kick") reaches anything without its own named selector.
Predicates
Wyscout puts almost everything in numeric tags, not fields — whether a pass found its
target, whether a shot was a goal, which foot took it. hasTag is the primitive; the named
predicates are built on it:
import {
hasTag,
isAccurate,
isGoal,
isKeyPass,
wonDuel,
WYSCOUT_TAGS,
} from "@pitchkit/data-providers/wyscout";
const goals = goalAttempts(events).filter(isGoal);
const completed = passes(events).filter(isAccurate);
const chances = passes(events).filter(isKeyPass);
// isAccurate is really just:
hasTag(somePass, WYSCOUT_TAGS.ACCURATE);Accuracy is explicit on both sides. Every pass carries either ACCURATE (1801) or
NOT_ACCURATE (1802) — unlike StatsBomb, where a completed pass is the absence of an outcome.
isAccurate(pass) is a real equality check, not a workaround.
A goal is tagged twice
The GOAL tag sits on the attempt that scored and on the conceding keeper's
Save attempt — Wyscout tags the outcome from both sides of the same event. Measured across
six full matches: tag 101 appeared on 15 shots, 19 save attempts, and 3 free kicks.
Filter the whole feed for isGoal and every goal is counted twice. Narrow to
goalAttempts(events) first, then filter, as the example above does. shots(events) is too
narrow: it misses every goal scored from a penalty or direct free kick.
Goals are not the score
Two more things stand between goalAttempts(events).filter(isGoal) and the scoreline, and
goalsFor handles both:
- Own goals carry a different tag. An own goal is tag 102 (
isOwnGoal), not 101, on the event of the player who put it in, usuallyOthers on the ball. ItsteamIdis the side that conceded. - Shootout kicks are attempts too. They are penalties in
matchPeriod: "P", and don't count towards the score.
import { goalsFor, teamIds } from "@pitchkit/data-providers/wyscout";
const [a, b] = teamIds(match);
goalsFor(match.events, a); // goal attempts that scored, plus the other side's own goals
goalsFor(match.events, b);In the 2018 World Cup final, France 4–2 Croatia, shots(events).filter(isGoal) finds four:
Griezmann's penalty is a Free Kick, and Mandžukić's own goal is a tagged clearance.
A shot has no end coordinate
Every event carries a positions array, and most have two entries — a start and an end. A
Shot's second entry is a placeholder, not a location: across 9,765 events checked, it was
always exactly (100, 100) or (0, 0), never a real point. Interruption and Offside
behave the same way. So endX/endY are simply absent on those three event types rather
than present and wrong.
Where a shot went is recorded instead as one of 23 goal-mouth tags — shotGoalZone reads
them:
import { shotGoalZone } from "@pitchkit/data-providers/wyscout";
shotGoalZone(goal); // "goal low left", "out high right", …This can't be caught by checking the coordinate's value — (100, 100) is also a genuine
corner-flag position for a corner kick. The exclusion has to be keyed on the event type, not on
what the number happens to be.
Coordinates point at the goal being attacked
Like SkillCorner's dynamic events, and unlike its tracking file: x is normalised to the
attacking direction, not absolute. x: 100 is always the goal that event's team is
attacking, in both halves, so both teams appear to attack left-to-right and nothing flips at
half time. <Pitch type="wyscout"> plots x/y raw — no lifting, no conversion.
Reading the fields
<Scatter
data={goalAttempts(events)}
x={(shot) => shot.x}
y={(shot) => shot.y}
fill={(shot) => (isGoal(shot) ? "orange" : "steelblue")}
tooltip={(shot) => (isGoal(shot) ? "Goal" : (shotGoalZone(shot) ?? "Blocked"))}
/>x/y are lifted from positions[0] for every event that has one. endX/endY are lifted
the same way from positions[1] — except on Shot, Interruption and Offside, per above.
Official documentation
This page covers loading the data; Wyscout's own tag and event vocabularies are the authority on what each id means:
- A public data set of spatio-temporal match events in soccer competitions — Pappalardo et al., Scientific Data (2019), the paper the dataset accompanies.
- figshare collection — the official release: competitions, teams, players, and the tag/event-id vocabularies this package's predicates are built from.
- koenvo/wyscout-soccer-match-event-dataset
— the mirror this package's
fetchMatchreads from, which splits the official archive into one file per match with no field renamed.
CC BY 4.0 — cite Pappalardo et al. (2019) in anything you publish from it.