knitr::opts_chunk$set( collapse = TRUE, comment = '#>', fig.align = 'center', out.width = '92%', fig.width = 7, fig.height = 4.6 ) make_table <- function(x, caption, digits = 3) { knitr::kable(x, caption = caption, digits = digits) }
The second night of a back-to-back is one of hockey's favorite pregame excuses. It sounds reasonable: tired legs, shorter meetings, travel, less goalie certainty, no real practice day. But schedule complaints are easy to overstate.
This guided example asks a league-wide question:
In the salary-cap era, how much worse do teams perform when they play with zero days of rest?
We will use nhlscraper::games() to build one row per team-game, calculate rest
from each team's previous game date, and compare win rate and goal differential.
The source table has one row per game. Rest is a team-level property, so each game becomes two records: one for the home team and one for the away team.
# Pull game and team catalogs. games_tbl <- nhlscraper::games() teams_tbl <- nhlscraper::teams() # Keep completed salary-cap regular-season games. games_tbl <- games_tbl[ games_tbl[['seasonId']] >= 20052006 & games_tbl[['gameTypeId']] == 2 & !is.na(games_tbl[['homeScore']]) & !is.na(games_tbl[['visitingScore']]), c( 'gameId', 'seasonId', 'gameDate', 'homeTeamId', 'visitingTeamId', 'homeScore', 'visitingScore' ) ] # Expand games into team-game rows. home_games <- data.frame( gameId = games_tbl[['gameId']], seasonId = games_tbl[['seasonId']], gameDate = as.Date(games_tbl[['gameDate']]), teamId = games_tbl[['homeTeamId']], isHome = TRUE, goalsFor = games_tbl[['homeScore']], goalsAgainst = games_tbl[['visitingScore']] ) away_games <- data.frame( gameId = games_tbl[['gameId']], seasonId = games_tbl[['seasonId']], gameDate = as.Date(games_tbl[['gameDate']]), teamId = games_tbl[['visitingTeamId']], isHome = FALSE, goalsFor = games_tbl[['visitingScore']], goalsAgainst = games_tbl[['homeScore']] ) team_games <- rbind(home_games, away_games) # Sort within team. team_games <- team_games[order( team_games[['teamId']], team_games[['gameDate']], team_games[['gameId']] ), ] # Compute previous game date within team. team_games[['previousGameDate']] <- as.Date(NA) for (team_id in unique(team_games[['teamId']])) { idx <- which(team_games[['teamId']] == team_id) team_games[['previousGameDate']][idx] <- c( as.Date(NA), utils::head(team_games[['gameDate']][idx], -1) ) } # Create rest and result fields. team_games[['restDays']] <- as.integer(team_games[['gameDate']] - team_games[['previousGameDate']]) - 1L team_games <- team_games[!is.na(team_games[['restDays']]), ] team_games[['restBucket']] <- ifelse( team_games[['restDays']] >= 3, '3+', as.character(team_games[['restDays']]) ) team_games[['restBucket']] <- factor( team_games[['restBucket']], levels = c('0', '1', '2', '3+') ) team_games[['win']] <- team_games[['goalsFor']] > team_games[['goalsAgainst']] team_games[['goalDiff']] <- team_games[['goalsFor']] - team_games[['goalsAgainst']] nrow(team_games)
The definition is literal: restDays = 0 means the team played yesterday. That
is the second night of a back-to-back.
First we compare all team-games by rest bucket.
# Summarize results by rest bucket. rest_summary <- aggregate( cbind(win, goalDiff) ~ restBucket, data = team_games, FUN = mean ) rest_counts <- as.data.frame(table(team_games[['restBucket']])) names(rest_counts) <- c('restBucket', 'games') rest_summary <- merge(rest_summary, rest_counts, by = 'restBucket') rest_summary <- rest_summary[ match(levels(team_games[['restBucket']]), rest_summary[['restBucket']]), c('restBucket', 'games', 'win', 'goalDiff') ] make_table( rest_summary, caption = 'Win rate and average goal differential by rest bucket.', digits = 3 )
The zero-rest penalty is visible in both columns. Teams on a back-to-back win less often and get outscored on average. The biggest improvement comes from moving from zero days of rest to one.
# Plot win rate and goal differential by rest bucket. old_par <- graphics::par(no.readonly = TRUE) graphics::par(mfrow = c(1, 2), mar = c(5, 4, 3, 1)) graphics::barplot( rest_summary[['win']], names.arg = rest_summary[['restBucket']], col = c('#d62828', '#f77f00', '#fcbf49', '#90be6d'), border = NA, ylim = c(0, 0.6), xlab = 'Days of Rest', ylab = 'Win Rate' ) graphics::abline(h = mean(team_games[['win']]), lty = 2, col = '#495057') graphics::barplot( rest_summary[['goalDiff']], names.arg = rest_summary[['restBucket']], col = c('#d62828', '#f77f00', '#fcbf49', '#90be6d'), border = NA, xlab = 'Days of Rest', ylab = 'Average Goal Differential' ) graphics::abline(h = 0, lty = 2, col = '#495057') graphics::par(old_par)
Back-to-backs are not all equal. A tired team at home is still in a better spot than a tired team on the road.
# Summarize rest effect by venue. venue_summary <- aggregate( cbind(win, goalDiff) ~ restBucket + isHome, data = team_games, FUN = mean ) venue_counts <- aggregate( gameId ~ restBucket + isHome, data = team_games, FUN = length ) names(venue_counts)[names(venue_counts) == 'gameId'] <- 'games' venue_summary <- merge( venue_summary, venue_counts, by = c('restBucket', 'isHome') ) venue_summary[['venue']] <- ifelse( venue_summary[['isHome']], 'Home', 'Away' ) venue_summary <- venue_summary[, c( 'restBucket', 'venue', 'games', 'win', 'goalDiff' )] make_table( venue_summary, caption = 'Rest effect split by home and road games.', digits = 3 )
# Plot venue-specific rest curves. home_rows <- venue_summary[venue_summary[['venue']] == 'Home', ] away_rows <- venue_summary[venue_summary[['venue']] == 'Away', ] graphics::plot( seq_len(nrow(home_rows)), home_rows[['win']], type = 'b', pch = 19, lwd = 2, col = '#1d3557', xaxt = 'n', ylim = c(0.34, 0.62), xlab = 'Days of Rest', ylab = 'Win Rate' ) graphics::lines( seq_len(nrow(away_rows)), away_rows[['win']], type = 'b', pch = 19, lwd = 2, col = '#e63946' ) graphics::axis( side = 1, at = seq_len(nrow(home_rows)), labels = home_rows[['restBucket']] ) graphics::legend( 'bottomright', legend = c('Home', 'Away'), col = c('#1d3557', '#e63946'), pch = 19, lwd = 2, bty = 'n' )
The lines stay separated. Home ice helps, rest helps, and the worst combination is exactly the one coaches complain about most: no rest on the road.
The league can reduce pain by reducing the share of team-games played on zero rest. We can track that share by season.
# Summarize zero-rest share by season. season_rest <- aggregate( I(restDays == 0) ~ seasonId, data = team_games, FUN = mean ) names(season_rest)[names(season_rest) == 'I(restDays == 0)'] <- 'zeroRestShare' season_rest <- season_rest[order(season_rest[['seasonId']]), ] season_text <- as.character(season_rest[['seasonId']]) season_rest[['season']] <- paste0( substr(season_text, 1, 4), '-', substr(season_text, 7, 8) ) make_table( utils::tail(season_rest[, c('season', 'zeroRestShare')], 8), caption = 'Recent share of team-games played on zero rest.', digits = 3 )
# Plot season trend in zero-rest games. season_x <- seq_len(nrow(season_rest)) label_idx <- seq(1L, nrow(season_rest), by = 2L) old_par <- graphics::par(no.readonly = TRUE) graphics::par(mar = c(7, 4, 3, 1)) graphics::plot( season_x, season_rest[['zeroRestShare']], type = 'h', lwd = 3, col = '#457b9d', xaxt = 'n', xlab = '', ylab = 'Zero-Rest Share' ) graphics::points( season_x, season_rest[['zeroRestShare']], pch = 19, col = '#1d3557' ) graphics::axis( side = 1, at = season_x[label_idx], labels = season_rest[['season']][label_idx], las = 2, cex.axis = 0.75 ) graphics::mtext('Season', side = 1, line = 5) graphics::par(old_par)
This turns the article from "back-to-backs are hard" into a second question: how often does the league ask teams to absorb that cost?
Once the team-game table exists, a league-wide question can become a team identity question.
# Rank teams by zero-rest win rate. zero_rest_tbl <- team_games[ team_games[['restDays']] == 0, c('teamId', 'win', 'goalDiff') ] zero_summary <- aggregate( cbind(win, goalDiff) ~ teamId, data = zero_rest_tbl, FUN = mean ) zero_counts <- aggregate( win ~ teamId, data = zero_rest_tbl, FUN = length ) names(zero_counts)[names(zero_counts) == 'win'] <- 'games' zero_summary <- merge(zero_summary, zero_counts, by = 'teamId') zero_summary <- zero_summary[zero_summary[['games']] >= 50, ] zero_summary <- merge( zero_summary, teams_tbl[, c('teamId', 'teamTriCode')], by = 'teamId', all.x = TRUE ) best_zero <- zero_summary[order(-zero_summary[['win']]), ] best_zero <- utils::head(best_zero[, c( 'teamTriCode', 'games', 'win', 'goalDiff' )], 8) worst_zero <- zero_summary[order(zero_summary[['win']]), ] worst_zero <- utils::head(worst_zero[, c( 'teamTriCode', 'games', 'win', 'goalDiff' )], 8) make_table( best_zero, caption = 'Best zero-rest win rates among teams with at least 50 games.', digits = 3 ) make_table( worst_zero, caption = 'Lowest zero-rest win rates among teams with at least 50 games.', digits = 3 )
This is where a broad endpoint becomes fan-readable. The same reshaped table can support league averages, venue splits, season trends, and team debates.
Back-to-backs are not just a broadcast excuse. In the salary-cap era, zero-rest
teams win less often and carry worse goal differential. The penalty is sharpest
on the road, and the league-wide cost is large enough to be visible with only
games() and a careful reshape.
The broader lesson is methodological: nhlscraper endpoints often start as
simple catalogs, but the interesting questions appear after you change the unit
of analysis. Here, one row per game became one row per team-game, and the
schedule suddenly had a measurable price.
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.