Skip to contents

Overview

Florida beat Edmonton 2-1 in Game 7 of the 2024 Stanley Cup Final, and the result instantly entered hockey memory as one of those games that felt like it might hinge on a single bounce. That is exactly why it is such a good guided example. A one-goal final score leaves room for multiple stories. Maybe Florida truly carried play and deserved the win. Maybe Edmonton created just as much and lost on finishing. Maybe the game was close on everything, including chance quality. The only way to separate those stories is to walk from the scoreboard down into the event log. This example uses nhlscraper::gc_summary(), nhlscraper::gc_pbp(), nhlscraper::game_rosters(), and nhlscraper::calculate_expected_goals() to reconstruct the game at the chance level. We will move from team totals, to period context, to the biggest individual looks, and finally to two visuals that show how the game breathed.

Pull Game Summary and Play-By-Play

We work from the final itself, Game 7 of the 2024 Final (2023030417).

# Pull summary, play-by-play, and roster context.
game_summary <- nhlscraper::gc_summary(2023030417)
pbp_xg <- nhlscraper::calculate_expected_goals(
  nhlscraper::gc_pbp(2023030417)
)
rosters <- nhlscraper::game_rosters(2023030417)

# Build team and player labels.
home_id <- game_summary[['homeTeam']][['id']]
away_id <- game_summary[['awayTeam']][['id']]
home_abbrev <- game_summary[['homeTeam']][['abbrev']]
away_abbrev <- game_summary[['awayTeam']][['abbrev']]

rosters[['playerFullName']] <- paste(
  rosters[['playerFirstName']],
  rosters[['playerLastName']]
)
rosters[['teamTriCode']] <- ifelse(
  rosters[['teamId']] == home_id,
  home_abbrev,
  away_abbrev
)

# Keep shot events with positive xG.
shots <- pbp_xg[!is.na(pbp_xg[['xG']]) & pbp_xg[['xG']] > 0, , drop = FALSE]
shots <- merge(
  shots,
  rosters[, c('playerId', 'playerFullName', 'teamTriCode')],
  by.x = 'shootingPlayerId',
  by.y = 'playerId',
  all.x = TRUE
)
shots[['timeInPeriod']] <- sprintf(
  '%02d:%02d',
  shots[['secondsElapsedInPeriod']] %/% 60,
  shots[['secondsElapsedInPeriod']] %% 60
)

This is already a powerful workflow. With one summary call, one play-by-play call, one roster call, and one modeling helper, the game opens up from scoreline to process.

Start With Scoreboard Context

Before getting fancy, it helps to put the official result and the xG estimate side by side.

# Summarize team-level scoreboard and xG results.
team_table <- data.frame(
  team = c(home_abbrev, away_abbrev),
  goals = c(
    game_summary[['homeTeam']][['score']],
    game_summary[['awayTeam']][['score']]
  ),
  shotsOnGoal = c(
    game_summary[['homeTeam']][['sog']],
    game_summary[['awayTeam']][['sog']]
  ),
  xG = c(
    sum(shots[['xG']][shots[['eventOwnerTeamId']] == home_id], na.rm = TRUE),
    sum(shots[['xG']][shots[['eventOwnerTeamId']] == away_id], na.rm = TRUE)
  )
)
team_table[['xGPerShot']] <-
  team_table[['xG']] / team_table[['shotsOnGoal']]
make_table(
  team_table,
  caption = 'Game 7 team context: score, shots on goal, and expected goals.'
)
Game 7 team context: score, shots on goal, and expected goals.
team goals shotsOnGoal xG xGPerShot
FLA 2 21 2.279 0.109
EDM 1 24 2.533 0.106

The result and the process point in the same direction, but only narrowly. Florida edges Edmonton in goals, edges Edmonton in raw xG, and edges Edmonton in xG per shot. Nothing in this table looks like domination. It looks like a deserved win in a game where the margin stayed small all night.

Break the Night Into Periods

Game 7s rarely play with a single rhythm from start to finish. One team can own the first period, another can chase late, and the game can still end within one chance. Period-level xG helps show where the pressure actually accumulated.

# Summarize xG by period and team.
home_periods <- aggregate(
  xG ~ periodNumber,
  data = shots[shots[['eventOwnerTeamId']] == home_id, c('periodNumber', 'xG')],
  FUN = sum
)
away_periods <- aggregate(
  xG ~ periodNumber,
  data = shots[shots[['eventOwnerTeamId']] == away_id, c('periodNumber', 'xG')],
  FUN = sum
)
period_table <- merge(
  home_periods,
  away_periods,
  by = 'periodNumber',
  all = TRUE,
  suffixes = c('_home', '_away')
)
names(period_table) <- c(
  'period',
  paste0(home_abbrev, '_xG'),
  paste0(away_abbrev, '_xG')
)
period_table[is.na(period_table)] <- 0
make_table(
  period_table,
  caption = 'Expected goals by period in Game 7.'
)
Expected goals by period in Game 7.
period FLA_xG EDM_xG
1 0.618 0.547
2 0.415 0.833
3 1.247 1.153

Florida’s biggest push arrives in the third period. That matters because it matches the feel of the game: Edmonton kept hanging around, but Florida generated the most forceful late stretch and did enough to keep the Oilers from turning their chase into an equalizer.

Surface the Biggest Chances

Totals are useful, but they can still feel abstract. One of the nicest things about working directly from the event log is that you can ask which specific looks drove the game.

# Show largest individual shot-quality events.
chance_table <- shots[order(-shots[['xG']]), c(
  'playerFullName',
  'teamTriCode',
  'periodNumber',
  'timeInPeriod',
  'xCoordNorm',
  'yCoordNorm',
  'xG'
)]
chance_table <- utils::head(chance_table, 10)
names(chance_table) <- c(
  'player',
  'team',
  'period',
  'timeInPeriod',
  'xCoordNorm',
  'yCoordNorm',
  'xG'
)
make_table(
  chance_table,
  caption = 'Highest-xG individual chances in Game 7.',
  digits = 3
)
Highest-xG individual chances in Game 7.
player team period timeInPeriod xCoordNorm yCoordNorm xG
61 Evan Rodrigues FLA 3 19:33 16 17 0.444
16 Zach Hyman EDM 3 12:56 79 2 0.235
41 Sam Bennett FLA 3 05:17 81 5 0.211
17 Zach Hyman EDM 3 12:57 85 1 0.207
25 Mattias Janmark EDM 1 06:44 77 -2 0.200
8 Mattias Ekholm EDM 3 17:45 74 9 0.163
11 Mattias Ekholm EDM 3 17:42 84 3 0.132
39 Leon Draisaitl EDM 2 04:27 75 -26 0.117
59 Connor McDavid EDM 3 17:17 82 3 0.117
69 Matthew Tkachuk FLA 1 18:41 84 -8 0.111

That table is a nice reminder that a one-game study can still be concrete. Instead of saying “Florida had a slight edge,” you can point to the exact attempts that shaped the edge and where they came from on the ice.

Plot the Cumulative xG Race

The cumulative view shows whether the game felt close because both teams traded dangerous looks, or because one team held a long territorial edge that never quite broke the score open.

# Build cumulative xG paths for both teams.
build_cum_path <- function(team_id) {
  team_shots <- shots[
    shots[['eventOwnerTeamId']] == team_id,
    c('eventId', 'secondsElapsedInGame', 'xG')
  ]
  team_shots <- team_shots[order(
    team_shots[['secondsElapsedInGame']],
    team_shots[['eventId']]
  ), ]
  data.frame(
    minutes = c(0, team_shots[['secondsElapsedInGame']] / 60),
    cumXG = c(0, cumsum(team_shots[['xG']]))
  )
}
home_path <- build_cum_path(home_id)
away_path <- build_cum_path(away_id)
graphics::plot(
  home_path[['minutes']],
  home_path[['cumXG']],
  type = 's',
  lwd = 2,
  col = '#c1121f',
  xlim = c(0, 60),
  ylim = c(0, max(c(home_path[['cumXG']], away_path[['cumXG']])) * 1.05),
  xlab = 'Minutes Elapsed',
  ylab = 'Cumulative Expected Goals'
)
graphics::lines(
  away_path[['minutes']],
  away_path[['cumXG']],
  type = 's',
  lwd = 2,
  col = '#003049'
)
graphics::abline(v = c(20, 40), lty = 3, col = '#8d99ae')
graphics::legend(
  'topleft',
  legend = c(home_abbrev, away_abbrev),
  col = c('#c1121f', '#003049'),
  lwd = 2,
  bty = 'n'
)
Cumulative expected goals in Game 7 of the 2024 Stanley Cup Final.

Cumulative expected goals in Game 7 of the 2024 Stanley Cup Final.

The lines stay close for almost the entire game. Florida pulls slightly ahead, then widens that edge late, but the overall shape is still a race rather than a runaway. That makes this final a clean example of a winner who both earned the result and still lived inside a narrow margin.

Plot Shot Geography

The rink view adds one more layer. A chance model tells us how dangerous the looks were. A shot map shows where those looks came from.

# Split shot map inputs by team.
home_shots <- shots[shots[['eventOwnerTeamId']] == home_id, ]
away_shots <- shots[shots[['eventOwnerTeamId']] == away_id, ]
nhlscraper::draw_NHL_rink()
graphics::points(
  home_shots[['xCoordNorm']],
  home_shots[['yCoordNorm']],
  pch = 19,
  col = grDevices::rgb(0.76, 0.07, 0.12, 0.55),
  cex = 0.6 + 7 * sqrt(home_shots[['xG']])
)
graphics::points(
  away_shots[['xCoordNorm']],
  away_shots[['yCoordNorm']],
  pch = 19,
  col = grDevices::rgb(0.00, 0.19, 0.29, 0.55),
  cex = 0.6 + 7 * sqrt(away_shots[['xG']])
)
graphics::legend(
  'topright',
  legend = c(home_abbrev, away_abbrev),
  pch = 19,
  col = c(
    grDevices::rgb(0.76, 0.07, 0.12, 0.75),
    grDevices::rgb(0.00, 0.19, 0.29, 0.75)
  ),
  bty = 'n'
)
Shot-quality map for Game 7. Point size scales with expected goals.

Shot-quality map for Game 7. Point size scales with expected goals.

The map reinforces the same read as the cumulative chart. Florida did not swamp Edmonton with endless pressure, but the Panthers owned just a little more of the dangerous interior. In a 2-1 Game 7, that is often the whole story.

What We Learned

Florida’s win was narrow, but it was not random. The Panthers generated slightly more xG, carried the stronger third-period push, and owned a small edge in dangerous interior looks. Edmonton stayed within range all the way through, which is why the game felt so tense, but the underlying shot-quality story still leans Florida. This is also a good showcase of the package itself. nhlscraper lets you start with a game everybody remembers, scrape the event log, attach an xG model, and turn a familiar score into a richer explanation of why the score looked the way it did.