Skip to contents

Overview

Playoff hockey has a stubborn stereotype: once the checking tightens, skill supposedly gives way to mass. Bigger skaters are assumed to survive the grind better, win more net-front battles, and keep producing when series turn meaner. That story sounds plausible, but it bundles two different ideas together. The first is about absolute playoff scoring: do heavier skaters actually score more? The second is about playoff translation: even if bigger players do not outscore smaller ones outright, do they lose less offense when the regular season gives way to the postseason? This example tackles both questions at once. We combine nhlscraper::skater_playoff_statistics(), nhlscraper::skater_statistics(), and nhlscraper::players() to build a salary-cap-era sample of skaters with meaningful regular-season and playoff workloads. From there, we compare regular-season points per game, playoff points per game, and the gap between them.

Build Analysis Table

We want one row per player with career regular-season scoring, career playoff scoring, and basic biometrics.

# Pull playoff, regular-season, and biometric records.
playoff_stats <- nhlscraper::skater_playoff_statistics()
career_stats <- nhlscraper::skater_statistics()[, c(
  'playerId',
  'rsGamesPlayed',
  'rsPoints',
  'positionCode'
)]
player_bios <- nhlscraper::players()[, c(
  'playerId',
  'playerFullName',
  'height',
  'weight'
)]

# Join player-level tables.
analysis_tbl <- merge(
  playoff_stats,
  career_stats,
  by = c('playerId', 'positionCode'),
  all.x = TRUE
)
analysis_tbl <- merge(
  analysis_tbl,
  player_bios,
  by = 'playerId',
  all.x = TRUE
)

# Keep salary-cap skaters with meaningful samples.
analysis_tbl <- analysis_tbl[
  !is.na(analysis_tbl[['height']]) &
    !is.na(analysis_tbl[['weight']]) &
    analysis_tbl[['firstSeasonForGameType']] >= 20052006 &
    analysis_tbl[['gamesPlayed']] >= 20 &
    analysis_tbl[['rsGamesPlayed']] >= 200,
]

# Fill missing names and compute scoring rates.
analysis_tbl[['playerFullName']] <- ifelse(
  is.na(analysis_tbl[['playerFullName']]) |
    analysis_tbl[['playerFullName']] == '',
  paste(
    analysis_tbl[['skaterFirstName']],
    analysis_tbl[['skaterLastName']]
  ),
  analysis_tbl[['playerFullName']]
)
analysis_tbl[['regularPPG']] <-
  analysis_tbl[['rsPoints']] / analysis_tbl[['rsGamesPlayed']]
analysis_tbl[['playoffPPG']] <-
  analysis_tbl[['points']] / analysis_tbl[['gamesPlayed']]
analysis_tbl[['playoffLift']] <-
  analysis_tbl[['playoffPPG']] - analysis_tbl[['regularPPG']]
analysis_tbl[['positionBucket']] <- ifelse(
  analysis_tbl[['positionCode']] == 'D',
  'Defense',
  'Forward'
)

# Assign equal-count weight quartiles.
weight_share <- rank(
  analysis_tbl[['weight']],
  ties.method = 'first'
) / nrow(analysis_tbl)
analysis_tbl[['weightQuartile']] <- cut(
  weight_share,
  breaks = c(0, 0.25, 0.50, 0.75, 1),
  include.lowest = TRUE,
  labels = c('Lightest', 'Second', 'Third', 'Heaviest')
)
nrow(analysis_tbl)
#> [1] 1680

That leaves 1680 modern skaters. The sample is large enough to smooth away one-hot playoff runs, but still focused enough to keep the comparison about contemporary hockey. To make the shape of the data concrete, it helps to peek at a few of the strongest playoff scorers in the sample.

# Show sample of strongest playoff scorers.
sample_tbl <- analysis_tbl[
  order(-analysis_tbl[['playoffPPG']], -analysis_tbl[['gamesPlayed']]),
  c(
    'playerFullName',
    'positionCode',
    'weight',
    'rsGamesPlayed',
    'gamesPlayed',
    'regularPPG',
    'playoffPPG',
    'playoffLift'
  )
]
sample_tbl <- utils::head(sample_tbl, 8)
make_table(
  sample_tbl,
  caption = 'Top playoff scoring rates among skaters in the working sample.'
)
Top playoff scoring rates among skaters in the working sample.
playerFullName positionCode weight rsGamesPlayed gamesPlayed regularPPG playoffPPG playoffLift
12088 Connor McDavid C 194 777 96 1.534 1.562 0.028
12089 Connor McDavid C 194 777 96 1.534 1.562 0.028
11907 Leon Draisaitl C 209 852 96 1.232 1.469 0.236
11908 Leon Draisaitl C 209 852 96 1.232 1.469 0.236
11792 Nathan MacKinnon C 200 932 95 1.201 1.316 0.115
11793 Nathan MacKinnon C 200 932 95 1.201 1.316 0.115
8431 Marian Hossa R 207 1309 20 0.866 1.300 0.434
12106 Mikko Rantanen R 228 706 81 1.096 1.247 0.151

Compare Weight Quartiles

Now we can step back from individual names and ask the population question. If heavier skaters really become more dangerous in the playoffs, the heaviest quartile should sit clearly above the lighter groups in playoff scoring rate, or at least show a meaningfully smaller drop from regular-season scoring.

# Summarize scoring levels and playoff lift by quartile.
quartile_summary <- aggregate(
  cbind(regularPPG, playoffPPG, playoffLift) ~ weightQuartile,
  data = analysis_tbl,
  FUN = mean
)
quartile_counts <- as.data.frame(table(analysis_tbl[['weightQuartile']]))
names(quartile_counts) <- c('weightQuartile', 'n')
quartile_summary <- merge(
  quartile_summary,
  quartile_counts,
  by = 'weightQuartile'
)
quartile_summary <- quartile_summary[
  match(levels(analysis_tbl[['weightQuartile']]), quartile_summary[['weightQuartile']]),
  c('weightQuartile', 'n', 'regularPPG', 'playoffPPG', 'playoffLift')
]
make_table(
  quartile_summary,
  caption = 'Regular-season scoring, playoff scoring, and playoff lift by weight quartile.'
)
Regular-season scoring, playoff scoring, and playoff lift by weight quartile.
weightQuartile n regularPPG playoffPPG playoffLift
2 Lightest 420 0.511 0.461 -0.050
3 Second 420 0.475 0.416 -0.059
4 Third 420 0.437 0.380 -0.057
1 Heaviest 420 0.418 0.378 -0.040

This is where the cliché starts to wobble. The lightest quartile posts the strongest playoff scoring rate in the sample at about 0.461 points per game, while the heaviest quartile sits at about 0.378. But the second question is more subtle. The heaviest skaters do not lead in raw playoff scoring, yet they also show the smallest drop from their own regular-season baseline. Their average playoff lift is about -0.040, compared with -0.050 for the lightest group. That is a more interesting result than a simple yes-or-no size verdict. Bigger skaters are not the most productive playoff scorers on average, but they may lose slightly less offense when conditions tighten.

Visualize Level Versus Translation

A table gives the means. A plot shows whether those means reflect broad tendencies or just a few stars.

# Plot playoff scoring distribution and average lift.
old_par <- graphics::par(no.readonly = TRUE)
graphics::par(mfrow = c(1, 2), mar = c(8, 4, 3, 1))
graphics::boxplot(
  playoffPPG ~ weightQuartile,
  data = analysis_tbl,
  col = c('#d9ed92', '#b5e48c', '#76c893', '#34a0a4'),
  border = '#3a5a40',
  las = 2,
  ylab = 'Playoff Points Per Game',
  xlab = ''
)
graphics::barplot(
  quartile_summary[['playoffLift']],
  names.arg = quartile_summary[['weightQuartile']],
  col = c('#f4d35e', '#ee964b', '#f95738', '#7b2cbf'),
  border = NA,
  las = 2,
  ylab = 'Playoff Lift',
  xlab = ''
)
graphics::abline(h = 0, lty = 2, col = '#4d4d4d')
Playoff scoring level and playoff lift by weight quartile.

Playoff scoring level and playoff lift by weight quartile.

graphics::par(old_par)

The left panel reinforces the first point: heavier groups do not shift upward as playoff scorers. The right panel reinforces the second: every group scores less in the playoffs than in the regular season on average, but the drop is a little shallower for the heaviest skaters. That combination makes intuitive hockey sense. Many larger players are not offense-first stars, so their absolute scoring rates start lower. But their game may translate a bit more cleanly into playoff conditions than the regular-season scoring records alone suggest.

See Who Gains Most

One way to make the translation question feel real is to look at the players with the biggest positive playoff bump.

# Show largest positive playoff lifts among larger-sample skaters.
risers_tbl <- analysis_tbl[
  analysis_tbl[['gamesPlayed']] >= 40,
  c(
    'playerFullName',
    'positionBucket',
    'weight',
    'regularPPG',
    'playoffPPG',
    'playoffLift'
  )
]
risers_tbl <- risers_tbl[order(-risers_tbl[['playoffLift']]), ]
risers_tbl <- utils::head(risers_tbl, 10)
make_table(
  risers_tbl,
  caption = 'Largest playoff scoring lifts among skaters with at least 40 playoff games.'
)
Largest playoff scoring lifts among skaters with at least 40 playoff games.
playerFullName positionBucket weight regularPPG playoffPPG playoffLift
8218 Daniel Brière Forward 174 0.715 1.059 0.344
12588 Evan Bouchard Defense 192 0.760 1.080 0.320
12589 Evan Bouchard Defense 192 0.760 1.080 0.320
11780 Artturi Lehkonen Forward 179 0.508 0.778 0.269
10817 Jakob Silfverberg Forward 207 0.455 0.719 0.264
11910 Sam Bennett Forward 193 0.510 0.766 0.256
11907 Leon Draisaitl Forward 209 1.232 1.469 0.236
11908 Leon Draisaitl Forward 209 1.232 1.469 0.236
12202 Evan Rodrigues Forward 182 0.438 0.672 0.234
10805 Ryan O’Reilly Forward 207 0.728 0.961 0.232

This table also hints at an important caveat: some of the best playoff translators are defensemen. That matters because defense is a position where the scoring baseline is lower to begin with. A small absolute scoring gain there can look like a meaningful playoff bump.

Fit Simple Model

To separate size from position a little more directly, we can fit a simple model with playoff lift as the response and height, weight, and a defense indicator as predictors.

# Fit simple playoff-lift model.
lift_fit <- stats::lm(
  playoffLift ~ height + weight + I(positionCode == 'D'),
  data = analysis_tbl
)
lift_fit_tbl <- as.data.frame(summary(lift_fit)$coefficients)
lift_fit_tbl[['term']] <- rownames(lift_fit_tbl)
rownames(lift_fit_tbl) <- NULL
lift_fit_tbl[['term']] <- c(
  'Intercept',
  'Height',
  'Weight',
  'Defense indicator'
)
lift_fit_tbl <- lift_fit_tbl[, c(
  'term',
  'Estimate',
  'Std. Error',
  't value',
  'Pr(>|t|)'
)]
make_table(
  lift_fit_tbl,
  caption = 'Linear model of playoff scoring lift on height, weight, and position.',
  digits = 4
)
Linear model of playoff scoring lift on height, weight, and position.
term Estimate Std. Error t value Pr(>|t|)
Intercept -0.0110 0.1218 -0.0900 0.9283
Height -0.0013 0.0021 -0.6209 0.5347
Weight 0.0002 0.0003 0.7811 0.4349
Defense indicator 0.0303 0.0068 4.4469 0.0000

In this specification, neither height nor weight carries a strong standalone slope once position is accounted for. The cleanest signal is positional: defensemen, on average, lose less scoring in the playoffs than forwards do. That does not mean size is irrelevant. It means the popular claim that bigger skaters as such become better playoff scorers is too blunt. Role and position explain more than a simple bigger-is-better story.

What We Learned

The modern playoff-size cliché is only half right. Bigger skaters do not post the best playoff scoring rates on average. The lightest quartile still scores more. But bigger skaters also do not collapse when the postseason starts; if anything, their scoring translates a bit more cleanly relative to regular-season expectations. That is a useful lesson for exploratory work with nhlscraper: the most interesting answer is often a split answer. A single workflow can move from headline myth, to player-level table, to population comparison, to a more careful interpretation of what the data really say.