Question
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.
Build Team-Games
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)
#> [1] 53256The definition is literal: restDays = 0 means the team
played yesterday. That is the second night of a back-to-back.
League-Wide Rest Curve
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
)| restBucket | games | win | goalDiff |
|---|---|---|---|
| 0 | 9025 | 0.431 | -0.263 |
| 1 | 29292 | 0.482 | 0.036 |
| 2 | 9981 | 0.493 | 0.112 |
| 3+ | 4958 | 0.477 | 0.040 |
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')
Team performance by days of rest.
graphics::par(old_par)Home Ice Does Not Erase Fatigue
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
)| restBucket | venue | games | win | goalDiff |
|---|---|---|---|---|
| 0 | Away | 6147 | 0.405 | -0.434 |
| 0 | Home | 2878 | 0.487 | 0.103 |
| 1 | Away | 13736 | 0.435 | -0.237 |
| 1 | Home | 15556 | 0.523 | 0.277 |
| 2 | Away | 4615 | 0.455 | -0.105 |
| 2 | Home | 5366 | 0.526 | 0.299 |
| 3+ | Away | 2130 | 0.446 | -0.166 |
| 3+ | Home | 2828 | 0.500 | 0.194 |
# 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'
)
Home and road win rate by rest bucket.
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.
Has the Schedule Become Kinder?
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
)| season | zeroRestShare | |
|---|---|---|
| 15 | 2019-20 | 0.163 |
| 16 | 2020-21 | 0.173 |
| 17 | 2021-22 | 0.165 |
| 18 | 2022-23 | 0.156 |
| 19 | 2023-24 | 0.137 |
| 20 | 2024-25 | 0.152 |
| 21 | 2025-26 | 0.164 |
| 22 | 2026-27 | 0.140 |
# 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)
Share of team-games played on zero rest by season.
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?
Team Leaderboard
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
)| teamTriCode | games | win | goalDiff | |
|---|---|---|---|---|
| 3 | NYR | 291 | 0.543 | 0.409 |
| 33 | VGK | 106 | 0.519 | 0.198 |
| 6 | BOS | 295 | 0.475 | 0.105 |
| 28 | SJS | 284 | 0.468 | -0.070 |
| 16 | CHI | 322 | 0.466 | -0.118 |
| 5 | PIT | 318 | 0.465 | -0.025 |
| 19 | STL | 307 | 0.463 | -0.062 |
| 12 | CAR | 333 | 0.462 | -0.141 |
make_table(
worst_zero,
caption = 'Lowest zero-rest win rates among teams with at least 50 games.',
digits = 3
)| teamTriCode | games | win | goalDiff | |
|---|---|---|---|---|
| 34 | SEA | 66 | 0.242 | -0.894 |
| 32 | ARI | 124 | 0.347 | -0.871 |
| 7 | BUF | 349 | 0.364 | -0.550 |
| 29 | CBJ | 342 | 0.374 | -0.561 |
| 22 | EDM | 243 | 0.383 | -0.510 |
| 11 | ATL | 95 | 0.389 | -0.779 |
| 4 | PHI | 322 | 0.401 | -0.553 |
| 23 | VAN | 261 | 0.402 | -0.433 |
This is where a broad endpoint becomes fan-readable. The same reshaped table can support league averages, venue splits, season trends, and team debates.
What We Learned
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.
