knitr::opts_chunk$set( collapse = TRUE, comment = '#>', fig.align = 'center', out.width = '92%', fig.width = 7, fig.height = 4.5 ) make_table <- function(x, caption, digits = 3) { knitr::kable(x, caption = caption, digits = digits) }
The play-by-play functions are the package's most opinionated tools. They do not simply flatten an NHL endpoint and hand the result back. They reconcile multiple public sources, repair a few known event-order problems, derive a large public schema, and add event-level on-ice player IDs from the official HTML report.
That makes gc_play_by_play() and wsc_play_by_play() extremely useful, but it
also means the output deserves a mental model. This article is that model. It is
strictly informational: the goal is to explain what the pipeline trusts, what it
derives, what it refuses to infer, and where shift_chart() fits.
gc_play_by_play() and wsc_play_by_play() start from different event feeds,
but both return a cleaned event table. The source split looks like this:
source_table <- data.frame( source = c( 'GameCenter API', 'World Showcase API', 'HTML play-by-play report', 'Stats shift-chart API', 'HTML shift reports' ), used_by = c( 'gc_play_by_play(), game metadata for WSC', 'wsc_play_by_play()', 'gc_play_by_play(), wsc_play_by_play()', 'shift_chart()', 'shift_chart() fallback' ), role = c( 'Authoritative event stream for GameCenter rows and common metadata.', 'Alternate event stream with UTC timestamps and fewer clip fields.', 'Primary source for event-level on-ice goalie and skater IDs.', 'Preferred source for player shift start/end clocks.', 'Fallback source for shift start/end clocks when stats API rows are missing.' ), stringsAsFactors = FALSE ) make_table( source_table, caption = 'Public sources used by the play-by-play and shift-chart pipeline.' )
The important separation is identity versus timing. HTML play-by-play reports are used to recover who was on the ice for an event. Shift charts are used later to calculate how long those players had been on the ice.
pbp <- nhlscraper::gc_play_by_play(2023030417) shifts <- nhlscraper::shift_chart(2023030417) pbp <- nhlscraper::add_shift_times(pbp, shifts)
The high-level flow is deliberately conservative. The API event feed remains the backbone. The HTML report is matched back onto that backbone. Derived features are added only after the event order and public naming are stable.
steps <- c( 'Fetch API and HTML', 'Normalize source names', 'Repair clear order defects', 'Parse situationCode', 'Normalize coordinates', 'Add shot context', 'Parse HTML on-ice rows', 'Match HTML to API rows', 'Finalize public schema' ) box_x <- c(1, 2, 3, 3, 2, 1, 1, 2, 3) box_y <- c(3, 3, 3, 2, 2, 2, 1, 1, 1) box_w <- 0.78 box_h <- 0.34 draw_box <- function(x, y, label, number) { wrapped_label <- paste(strwrap(paste0(number, '. ', label), width = 18), collapse = '\n') graphics::rect( xleft = x - box_w / 2, ybottom = y - box_h / 2, xright = x + box_w / 2, ytop = y + box_h / 2, col = '#edf6f9', border = '#006d77', lwd = 1.5 ) graphics::text( x = x, y = y, labels = wrapped_label, cex = 0.74, col = '#1f2933' ) } draw_arrow <- function(i, j) { x0 <- box_x[i] y0 <- box_y[i] x1 <- box_x[j] y1 <- box_y[j] graphics::arrows( x0 = x0 + sign(x1 - x0) * box_w / 2, y0 = y0 + sign(y1 - y0) * box_h / 2, x1 = x1 - sign(x1 - x0) * box_w / 2, y1 = y1 - sign(y1 - y0) * box_h / 2, length = 0.08, lwd = 1.3, col = '#6c757d' ) } graphics::plot( NA_real_, NA_real_, type = 'n', axes = FALSE, xlab = '', ylab = '', xlim = c(0.45, 3.55), ylim = c(0.55, 3.45) ) for (i in seq_len(length(steps) - 1L)) { draw_arrow(i, i + 1L) } for (i in seq_along(steps)) { draw_box(box_x[i], box_y[i], steps[i], i) }
The package is not trying to rewrite the game. It fixes only issues that are plainly incompatible with the event clock or with the package's public schema.
repair_table <- data.frame( issue = c( 'Source-specific column names', 'Goal row without shootingPlayerId', 'Blocked-shot zone perspective', 'Impossible period-boundary order', 'Missing public output columns' ), action = c( 'Rename to stable public names such as periodNumber and eventTypeDescKey.', 'Use scoringPlayerId as shootingPlayerId when the scorer is known.', 'Normalize blocked shots to the shooting-team perspective.', 'Drop or repair only the small set of rows that violate clock boundaries.', 'Allocate typed NA columns so GC and WSC outputs keep stable schemas.' ), stringsAsFactors = FALSE ) make_table( repair_table, caption = 'Conservative repairs made before public output is finalized.' )
The design goal is auditability. When something can be derived directly, the package derives it. When something would require a hockey assumption that cannot be verified from the public sources, the package leaves the public state alone.
situationCode Means State, Not IdentityThe raw situationCode is parsed into manpower state columns:
homeIsEmptyNetawayIsEmptyNethomeSkaterCountawaySkaterCountisEmptyNetForisEmptyNetAgainstskaterCountForskaterCountAgainstmanDifferentialstrengthStateThose columns describe the intended rules state of the event. They are not recalculated from the HTML on-ice player list. That distinction is important because the official HTML report can list extra skaters around line changes, delayed penalties, bench situations, or reporting quirks.
The HTML report is not joined to the API feed only by clock. A single game can have multiple events in the same second, and a clock-only join would create bad matches. The package builds matching signatures from:
match_table <- data.frame( feature = c( 'Period', 'Elapsed seconds', 'Event type', 'Event-owning team', 'Primary actor', 'Secondary and tertiary actors', 'Local event order' ), why_it_matters = c( 'Prevents same-clock matches across periods.', 'Keeps the match near the official event time.', 'Separates faceoffs, shots, hits, penalties, goals, and stoppages.', 'Prevents same-time events by opposite teams from swapping.', 'Anchors the event to the shooter, scorer, hitter, winner, or penalized player.', 'Disambiguates goals, blocks, faceoffs, hits, and penalties.', 'Avoids backwards matches in duplicate clusters.' ), stringsAsFactors = FALSE ) make_table( match_table, caption = 'Signals used when matching HTML rows back to API events.' )
Once a match is accepted, the HTML on-ice IDs are written into scalar columns:
homeGoaliePlayerId, awayGoaliePlayerId, homeSkater1PlayerId,
awaySkater1PlayerId, skater1PlayerIdFor, skater1PlayerIdAgainst, and so
on. Five skater slots are guaranteed; additional skater6, skater7, and
later slots appear only when a game requires them.
Penalty shots and shootouts are not ordinary strength states. Rows such as
0101 and 1010 are constrained to the shooter and defending goalie. Even if
the HTML report shows extra players, the public on-ice identity columns are kept
to the one-on-one context because that is the play being recorded.
Some delayed-penalty marker rows do not appear as ordinary event rows in the HTML report. When that happens, the package can backfill on-ice IDs from the nearest prior compatible event in the same period. The backfill is allowed only when the state signature is unchanged, the time gap is tiny, and the prior row already has a populated on-ice set. This is intentionally narrow.
The public schemas are aligned, but not identical. GameCenter rows include
GameCenter clip fields such as highlightClip, discreteClip, and
pptReplayUrl. WSC rows include utc immediately after game elapsed seconds
and omit the GC-only clip fields. That source-specific difference is intentional
because the source feeds do not expose exactly the same metadata.
Use this sequence when building event-level analysis:
# Load cleaned event stream. pbp <- nhlscraper::gc_play_by_play(2023030417) # Add shift timing when fatigue or shift length matters. shifts <- nhlscraper::shift_chart(2023030417) pbp <- nhlscraper::add_shift_times(pbp, shifts) # Add movement context when previous-event geometry matters. pbp <- nhlscraper::add_deltas(pbp) # Score shots when chance quality matters. pbp <- nhlscraper::calculate_expected_goals(pbp)
Use gc_play_by_play_raw() or wsc_play_by_play_raw() only when you want to
inspect the upstream feed itself. Use gc_play_by_play() or
wsc_play_by_play() when you want the package's public analysis schema.
The pipeline keeps three ideas separate:
situationCode defines the intended manpower state.That separation is what makes the output practical for research while still being honest about where each piece of information came from.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.