# How Much Do Back-to-Backs Cost?

## 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] 50568

The 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
    )

Win rate and average goal differential by rest bucket.
restBucket | games | win | goalDiff
--- | --- | --- | ---
0 | 8648 | 0.450 | -0.274
1 | 27756 | 0.508 | 0.038
2 | 9441 | 0.521 | 0.119
3+ | 4723 | 0.501 | 0.042

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
    )

Rest effect split by home and road games.
restBucket | venue | games | win | goalDiff
--- | --- | --- | --- | ---
0 | Away | 5895 | 0.422 | -0.453
0 | Home | 2753 | 0.509 | 0.107
1 | Away | 12979 | 0.461 | -0.250
1 | Home | 14777 | 0.550 | 0.292
2 | Away | 4375 | 0.480 | -0.111
2 | Home | 5066 | 0.557 | 0.317
3+ | Away | 2035 | 0.466 | -0.173
3+ | Home | 2688 | 0.526 | 0.205

    # 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
    )

Recent share of team-games played on zero rest.
| season | zeroRestShare
--- | --- | ---
14 | 2018-19 | 0.158
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

    # 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
    )

Best zero-rest win rates among teams with at least 50 games.
| teamTriCode | games | win | goalDiff
--- | --- | --- | --- | ---
3 | NYR | 280 | 0.564 | 0.425
33 | VGK | 98 | 0.561 | 0.214
6 | BOS | 281 | 0.498 | 0.110
28 | SJS | 271 | 0.491 | -0.074
5 | PIT | 303 | 0.488 | -0.026
16 | CHI | 310 | 0.484 | -0.123
19 | STL | 295 | 0.481 | -0.064
12 | CAR | 321 | 0.480 | -0.146

    make_table(
      worst_zero,
      caption = 'Lowest zero-rest win rates among teams with at least 50 games.',
      digits = 3
    )

Lowest zero-rest win rates among teams with at least 50 games.
| teamTriCode | games | win | goalDiff
--- | --- | --- | --- | ---
34 | SEA | 53 | 0.302 | -1.113
32 | ARI | 124 | 0.347 | -0.871
7 | BUF | 335 | 0.379 | -0.573
29 | CBJ | 329 | 0.389 | -0.584
11 | ATL | 95 | 0.389 | -0.779
22 | EDM | 232 | 0.401 | -0.534
4 | PHI | 310 | 0.416 | -0.574
10 | TOR | 303 | 0.419 | -0.416

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.