Inside the NHL EDGE Goal Visualizer: How We Scrape, Clean and Use Tracking Data

2026-09-30 · tracking · engineering · methodology

The NHL publishes about fourteen seconds of player tracking for every goal, ten frames a second. We hold 26,237 of them. How we fetch it, the four bugs we shipped handling it - none of which ever errored - what it is good for, and the one thing it can never do.

Figures computed 2026-09-19, against the models as they stood that day. We change them — see the methodology page for where they are now.

Every goal on the NHL’s EDGE site can be replayed from above: ten dots and a puck, gliding across a flat rink. It is one of the best things the league publishes, and behind the animation is data — every skater’s position, ten times a second, for about fourteen seconds before the puck goes in.

We store all of it. This is how: where it comes from, what it actually contains, the bugs we shipped while handling it, and what it turns out to be good for. It is the most technical post we have written, and most of it is about the ways it went wrong.


TL;DR

What the data actually is

For each goal, the league serves a JSON file of frames. Each frame is a tenth of a second and lists everyone on the ice — a player id, a team, a sweater number and an x/y position — plus the puck. That is the entire payload.

PropertyValue
Sample rate10 Hz — a timestamp in tenths of a second, one per frame
Clip length~140 frames, about 14 s ending at the goal (~120 in 2023-24)
CoordinatesInches on a 2400 × 1020 rink, so feet = value / 12
What is coveredGoals only
Seasons available2023-24 onward; earlier seasons return 403
What we hold26,237 goals, three seasons, over 40 million rows

Two things in that table matter more than everything else in this post.

It is goal-only. There are no saves, no missed shots, no shifts and no full games. What you get is the most unrepresentative sample of hockey it is possible to hold: every clip ends in the rarest event in the sport. Keep that in mind through every section below, because it is the constraint behind every limit we eventually hit.

It has a floor. The source serves 2023-24 and returns 403 for 2022-23 and every earlier season we sampled. We backfilled 2023-24 in September; there is nothing further back to get.

Getting it

Fetching is one request per goal to the league’s sprite host — wsr.nhle.com/sprites/{season}/{game}/ev{event}.json — keyed by the game and the play-by-play event number of the goal. The goal list comes from the play-by-play we already ingest, so there is no discovery step.

Three operational facts, all learned the slow way:

We ingest in the same nightly job that aggregates every game — since mid-September, anyway. Before that, nothing wrote tracking during the season at all: the step that was supposed to depended on a repository the deployed function did not contain, and in 150 days of logs it never once mentioned tracking. It did not fail. It was never there.


The four bugs, and why none of them errored

1. Our own site reported the frame rate at half its true value

For weeks the goal browser and our API described the feed as 5.1 frames a second, and so did we in a draft of a post about grading players. It is 10 Hz. The timestamp counts tenths of a second and advances by exactly one per frame; the 5.1 came out of a calculation that was circular. It mattered because the frame rate is an argument — at five frames a second you would conclude the data is too coarse to see a first step, and at ten you would not.

2. The coordinate scale changed on every goal

Downstream code works in a 0–85 “tracking unit” along the ice rather than in feet. The conversion from raw inches used that goal’s own maximum x value as the denominator, so a goal where somebody skated to the far blue line was drawn at a different scale from one that stayed below the dots. Every distance computed across goals was quietly comparing different rulers.

The fix is a fixed scale, the same on every goal. We rewrote the stored positions in place — 32,003,949 rows, with a backup table first — and every measurement built on distance changed with it.

3. A parallel backfill swapped clips between goals

This is the one worth reading if you build pipelines. In March a backfill ran goals through an older processing path on several threads at once. That path handed each goal through fixed shared files on disk — raw frames in, cleaned frames out, then into the database — so two goals processed at the same time could read each other’s intermediate file.

It wrote 2,970 goals. 80% of them ended up byte-identical to another goal in the same game. And 24% of the rest held a different goal’s clip — a goal from one end of the rink carrying the frames of a goal from the other.

The second number is the dangerous one, because a duplicate check cannot see a swap: every clip is unique, it is just attached to the wrong goal. What catches it is an independent source. The play-by-play records where each shot was taken, and the true puck path passes within a foot or two of that spot; when the spot sits more than ten feet from the path, the clip belongs to another goal.

We re-scraped all 2,970. There are now zero shared clips, and the shot spot sits off the puck path on 0.6% of goals, down from a quarter of the affected ones. The ingest code is now a single module that holds each goal in memory and writes it under its own key; the shared-file path is not allowed near ingestion again.

4. The rink was the wrong way round on about one goal in eleven

To say anything about a goal you need to know which net was being attacked. The first version inferred it from where the goaltender stood, which is right almost all the time and wrong on about 9% of goals — pulled goalies, scrambles, a goalie caught out of his crease. On those goals every read was mirrored.

The attacked end now comes from the play-by-play shot location, which sits a median of 1.3 feet from the tracked puck path. A different source, again, because the tracking cannot be trusted to check itself.

The through-line: none of these threw an error. Each produced a plausible number in the right place. Three of the four were found by checking the positions against something other than the positions — the feed’s own timestamp field, the play-by-play shot spot, a person looking at a replay and saying “that goal went in at the other end.”


What it is good for: reading a goal

What tracking does well is describe how a goal happened, and every such description needs something to be checked against. Ours is All Three Zones, Corey Sznajder’s manual tracking project, which has people watching games and labelling each goal — rush or forecheck or cycle, screened or not, odd-man or even. It covers a sample of games rather than every one, but that is exactly what you want for validation: labels made by humans who never saw our model.

The goal browser shows three reads. Each is a small fitted model, and each is scored on goals held out by game, then again on a whole season the models never saw:

ReadBuilt fromValidationUnseen season
Play typePuck depth 6 s and 4 s before the shotrush right 94%, in-zone 96%AUC 0.978
ScreenShot context + bodies in the puck-to-goalie laneAUC 0.910AUC 0.918
NumbersAttackers level with the puck v defenders goal-sideAUC 0.829—

The play-type read is the success. Two numbers — how deep in the zone the puck was six seconds and four seconds before the shot — separate rush goals from in-zone play better than the hand-tuned rules we started with, and it held on 2023-24 after being fit on the two seasons that followed. Goals in the ambiguous middle are left unclassified rather than guessed.

The screen read needs an honest footnote, and it is in the glossary too: it is mostly shot context. Distance, angle and shot type alone score 0.893; the tracking geometry adds 0.016. That is real, but small, and it would be easy to present the headline number as a tracking achievement when most of it is not. The numbers read could not be tested on the unseen season because the labellers recorded no odd-man rushes for it.

And what we took down

Two reads were live and are not any more, and both failures are more instructive than the successes.

“Screeners in the lane” counted bodies between the puck and the goaltender. Checked against the human labels, it called a goal screened and was right 14.4% of the time — below the 16.7% base rate, which is to say worse than guessing. The cause was a window: it counted bodies over the last 22 frames of the clip, and the last 22 frames of a goal clip are the celebration. It was measuring who skated in to hug the scorer.

Defensive scheme — collapse, broken coverage, man-to-man, zone — was the one we most wanted, and it failed every check we could run. It was stable frame to frame for man-to-man only 55% of the time. It called a net-front collapse on 38% of power-play goals against 7% at even strength, which is measuring the manpower, not the scheme. And its most common answer, “zone”, was 70% of all labels — the bucket everything fell into when nothing else fit. The structural problem was that it read one frame, and coverage is something that happens over time.


What it cannot do, and never will from this source

The obvious next step is a possession-value model: given where everyone is, how likely is this sequence to become a goal? It is the thing everyone wants from tracking, and this data cannot support it.

A model like that has to learn from possessions that did not score, and there are none here. We hold fewer than 9,000 goal clips a season against something like 170,000 shot attempts in the play-by-play. Train on goal clips alone and the model learns that every possession ends in a goal, because in its training data every possession did. Tracking-aware expected goals has the same problem for the same reason.

What is possible is narrower and still useful: a “shot from here” threat curve. Our expected-goals model is built from play-by-play fields — distance, angle, strength, rebound and rush flags, the previous event — and every one of them except shot type and speed can be computed at any frame from the tracked puck position. Run it frame by frame and you get how dangerous a shot would have been from where the puck is now, and you can compare the best chance available during a play with the one actually taken. That is a product decision we have not made yet.

Anything beyond that — tracking for saves, misses, full shifts — has to come from somewhere else. Broadcast video is the only candidate, and that is a different project.


If you are going to work with this data

You can see all of it — every goal, replayable from above with the reads alongside — in the goal browser.

Counts measured against production on 19 September 2026: 26,237 tracked goals across 2023-24, 2024-25 and 2025-26 (regular season, playoffs and 534 pre-season goals), and over 40 million position rows. Read validation figures are from our tracking write-up of 15–17 September 2026, scored against All Three Zones human labels on goals held out by game, and against the whole of 2023-24 after it was backfilled. The hero is a real clip — Jackson Blake, Carolina at Vegas, 14 June 2026 — drawn from the stored frames. Tracking data is the NHL’s; All Three Zones labels are Corey Sznajder’s. Hockey Alchemy is not affiliated with the NHL.

Frequently Asked Questions

What data is behind the NHL EDGE goal visualizer?

A JSON file of frames for each goal. Each frame is a tenth of a second and lists every player on the ice - id, team, sweater number and x/y position in inches on a 2400 by 1020 rink - plus the puck. A clip runs about fourteen seconds, roughly 140 frames, and ends at the goal.

What frame rate is NHL EDGE tracking data?

10 Hz. The timestamp counts tenths of a second and advances by exactly one per frame. Our own site described it as 5.1 frames a second for several weeks, from a circular calculation; that has been corrected.

Does the NHL publish tracking data for every shot?

No. The public goal-visualizer data covers goals only - no saves, missed shots, shifts or full games - and starts in 2023-24; earlier seasons return an error. That is why it cannot support a possession-value model: such a model has to learn from possessions that did not score, and there are none in the data.

What can goal tracking data be used for?

Describing how a goal happened. From puck depth four and six seconds before the shot we separate rush goals from in-zone play, right 94 and 96 percent of the time against All Three Zones human labels, and AUC 0.978 on a season the model never saw. Screen and odd-man reads work too, though the screen read is mostly shot context rather than tracking.

More from the Notebook

Talent vs Production: Why the Box Score Misleads

Two wingers score 20 goals; only one of them will do it again. The gap between what a player produced and the process underneath it is the most useful idea in hockey analytics - and it is the split our expected-goals and finishing models are built to make.

How Good Is Our WAR Model? We Ran Four Tests and Lost Two

We tested our WAR model against HockeyStats and Evolving Hockey on the four questions an honest player-value model has to answer, matched to each benchmark's published protocol. Summed to a team it loses both tests - it forecasts next season worse than simply reusing last season's standings. Measured per player it wins both, repeating at 0.77 against 0.62 and 0.46.

Which Hockey Stats Are Skill, and Which Are Luck?

Line up every player's value in a category this season against next season and you get a brutal skill-vs-luck test. Staying out of the penalty box repeats more than four times better than finishing - and it changes how you should read a stat line.