Do Elite Teams Build Offense the Same Way?

Question

“Good team” is not a style. Some good teams bury opponents in offensive-zone time. Some attack with speed. Some win with interior volume. Some create fear from the blue line and circles because every hard shot can become a rebound.

The NHL EDGE wrappers let us compare those styles directly. This article asks:

Do several elite 2024-25 teams manufacture offense in the same way, or do they have distinct tracking-data fingerprints?

We will compare five strong clubs:

League Leaders First

Before building profiles, look at a few league-leading team EDGE categories.

# Pull team EDGE leaders.
edge_leaders <- nhlscraper::team_edge_leaders(
  season    = 20242025,
  game_type = 2
)

# Build compact leader table.
leader_table <- data.frame(
  metric = c(
    'Shots over 90 mph',
    'Bursts over 22 mph',
    'Distance per 60',
    'High-danger shots on goal',
    'Offensive-zone time',
    'Neutral-zone time',
    'Defensive-zone time'
  ),
  team = c(
    edge_leaders[['shotAttemptsOver90']][['team']][['abbrev']],
    edge_leaders[['burstsOver22']][['team']][['abbrev']],
    edge_leaders[['distancePer60']][['team']][['abbrev']],
    edge_leaders[['highDangerSOG']][['team']][['abbrev']],
    edge_leaders[['offensiveZoneTime']][['team']][['abbrev']],
    edge_leaders[['neutralZoneTime']][['team']][['abbrev']],
    edge_leaders[['defensiveZoneTime']][['team']][['abbrev']]
  ),
  value = c(
    as.character(edge_leaders[['shotAttemptsOver90']][['attempts']]),
    as.character(edge_leaders[['burstsOver22']][['bursts']]),
    sprintf('%.2f miles', edge_leaders[['distancePer60']][['distanceSkated']][['imperial']]),
    as.character(edge_leaders[['highDangerSOG']][['sog']]),
    sprintf('%.3f', edge_leaders[['offensiveZoneTime']][['zoneTime']]),
    sprintf('%.3f', edge_leaders[['neutralZoneTime']][['zoneTime']]),
    sprintf('%.3f', edge_leaders[['defensiveZoneTime']][['zoneTime']])
  ),
  stringsAsFactors = FALSE
)
make_table(
  leader_table,
  caption = 'Selected 2024-25 team EDGE leaders.'
)
Selected 2024-25 team EDGE leaders.
metric team value
Shots over 90 mph EDM 136
Bursts over 22 mph COL 212
Distance per 60 FLA 9.34 miles
High-danger shots on goal EDM 714
Offensive-zone time CAR 0.461
Neutral-zone time DAL 0.187
Defensive-zone time CAR 0.355

The leader table is already a warning against one-size-fits-all thinking. The same team does not lead every tracking category.

Build Team Profiles

We will fetch four team-level views: zone time, skating speed, shot speed, and shot location. A few helper functions make the article more resilient when a nested EDGE response is temporarily incomplete.

# Define team set.
team_ids <- c(CAR = 12, COL = 21, EDM = 22, FLA = 13, WSH = 15)

# Define robust helpers.
fetch_with_retry <- function(fetch_fun, validator, tries = 3) {
  for (i in seq_len(tries)) {
    value <- try(fetch_fun(), silent = TRUE)
    if (!inherits(value, 'try-error') && validator(value)) {
      return(value)
    }
    Sys.sleep(i / 4)
  }
  NULL
}
valid_df <- function(x, required_cols) {
  is.data.frame(x) && nrow(x) > 0 && all(required_cols %in% names(x))
}
safe_num <- function(x) {
  out <- suppressWarnings(as.numeric(x))
  ifelse(is.na(out), NA_real_, out)
}
safe_name <- function(first_name, last_name) {
  if (
    is.na(first_name) ||
      is.na(last_name) ||
      first_name == '' ||
      last_name == ''
  ) {
    return(NA_character_)
  }
  paste(first_name, last_name)
}
safe_summary_num <- function(x, path) {
  value <- tryCatch({
    for (nm in path) x <- x[[nm]]
    x
  }, error = function(e) NA_real_)
  safe_num(value)
}

# Build one profile row.
build_team_profile <- function(team_code, team_id) {
  team_summary <- fetch_with_retry(
    function() nhlscraper::team_edge_summary(
      team      = team_id,
      season    = 20242025,
      game_type = 2
    ),
    function(x) is.list(x)
  )
  zone_rows <- fetch_with_retry(
    function() nhlscraper::team_edge_zone_time(
      team      = team_id,
      season    = 20242025,
      game_type = 2,
      category  = 'details'
    ),
    function(x) valid_df(x, c('strengthCode', 'offensiveZonePctg'))
  )
  skating_rows <- fetch_with_retry(
    function() nhlscraper::team_edge_skating_speed(
      team      = team_id,
      season    = 20242025,
      game_type = 2,
      category  = 'details'
    ),
    function(x) valid_df(x, c('positionCode', 'maxSkatingSpeed.imperial', 'burstsOver22.value'))
  )
  shot_speed_rows <- fetch_with_retry(
    function() nhlscraper::team_edge_shot_speed(
      team      = team_id,
      season    = 20242025,
      game_type = 2,
      category  = 'details'
    ),
    function(x) valid_df(x, c('position', 'topShotSpeed.imperial', 'shotAttempts90To100.value'))
  )
  shot_location_rows <- fetch_with_retry(
    function() nhlscraper::team_edge_shot_location(
      team      = team_id,
      season    = 20242025,
      game_type = 2,
      category  = 'details'
    ),
    function(x) valid_df(x, c('area', 'sog'))
  )
  if (
    is.null(zone_rows) ||
      is.null(skating_rows) ||
      is.null(shot_speed_rows) ||
      is.null(shot_location_rows)
  ) {
    return(data.frame(
      team = team_code,
      points = safe_summary_num(team_summary, c('team', 'points')),
      wins = safe_summary_num(team_summary, c('team', 'wins')),
      offensiveZonePctg = NA_real_,
      maxSkatingSpeed = NA_real_,
      burstsOver22 = NA_real_,
      shotAttemptsOver90 = NA_real_,
      hardestShot = NA_real_,
      trackedShots = NA_real_,
      interiorShare = NA_real_,
      circleShare = NA_real_,
      pointShare = NA_real_,
      fastestSkater = NA_character_,
      hardestShooter = NA_character_,
      stringsAsFactors = FALSE
    ))
  }
  zone_row <- zone_rows[zone_rows[['strengthCode']] == 'all', , drop = FALSE]
  skating_row <- skating_rows[skating_rows[['positionCode']] == 'all', , drop = FALSE]
  shot_speed_row <- shot_speed_rows[shot_speed_rows[['position']] == 'all', , drop = FALSE]
  if (!nrow(zone_row)) zone_row <- zone_rows[1, , drop = FALSE]
  if (!nrow(skating_row)) skating_row <- skating_rows[1, , drop = FALSE]
  if (!nrow(shot_speed_row)) shot_speed_row <- shot_speed_rows[1, , drop = FALSE]
  interior_mask <- shot_location_rows[['area']] %in% c(
    'Crease',
    'Low Slot',
    'L Net Side',
    'R Net Side'
  )
  circle_mask <- shot_location_rows[['area']] %in% c(
    'High Slot',
    'L Circle',
    'R Circle'
  )
  point_mask <- shot_location_rows[['area']] %in% c(
    'Center Point',
    'L Point',
    'R Point',
    'Outside L',
    'Outside R',
    'Beyond Red Line'
  )
  total_shots <- sum(shot_location_rows[['sog']], na.rm = TRUE)
  data.frame(
    team = team_code,
    points = safe_summary_num(team_summary, c('team', 'points')),
    wins = safe_summary_num(team_summary, c('team', 'wins')),
    offensiveZonePctg = safe_num(zone_row[['offensiveZonePctg']][1]),
    maxSkatingSpeed = safe_num(skating_row[['maxSkatingSpeed.imperial']][1]),
    burstsOver22 = safe_num(skating_row[['burstsOver22.value']][1]),
    shotAttemptsOver90 = sum(
      safe_num(shot_speed_row[['shotAttemptsOver100.value']][1]),
      safe_num(shot_speed_row[['shotAttempts90To100.value']][1]),
      na.rm = TRUE
    ),
    hardestShot = safe_num(shot_speed_row[['topShotSpeed.imperial']][1]),
    trackedShots = total_shots,
    interiorShare = sum(shot_location_rows[['sog']][interior_mask], na.rm = TRUE) / total_shots,
    circleShare = sum(shot_location_rows[['sog']][circle_mask], na.rm = TRUE) / total_shots,
    pointShare = sum(shot_location_rows[['sog']][point_mask], na.rm = TRUE) / total_shots,
    fastestSkater = safe_name(
      skating_row[['maxSkatingSpeed.overlay.player.firstName.default']][1],
      skating_row[['maxSkatingSpeed.overlay.player.lastName.default']][1]
    ),
    hardestShooter = safe_name(
      shot_speed_row[['topShotSpeed.overlay.player.firstName.default']][1],
      shot_speed_row[['topShotSpeed.overlay.player.lastName.default']][1]
    ),
    stringsAsFactors = FALSE
  )
}

# Build profile table.
team_profiles <- Map(
  build_team_profile,
  team_code = names(team_ids),
  team_id   = unname(team_ids)
)
team_profiles <- do.call(rbind, team_profiles)
rownames(team_profiles) <- NULL
profile_table <- team_profiles[, c(
  'team',
  'points',
  'wins',
  'offensiveZonePctg',
  'maxSkatingSpeed',
  'burstsOver22',
  'shotAttemptsOver90',
  'hardestShot',
  'interiorShare'
)]
make_table(
  profile_table,
  caption = 'Five-team 2024-25 EDGE profile comparison.',
  digits = 3
)
Five-team 2024-25 EDGE profile comparison.
team points wins offensiveZonePctg maxSkatingSpeed burstsOver22 shotAttemptsOver90 hardestShot interiorShare
CAR 99 47 0.461 24.491 98 65 100.14 0.316
COL 102 49 0.425 24.817 212 53 98.42 0.302
EDM 101 48 0.429 24.359 174 136 98.96 0.346
FLA 98 47 0.439 23.415 48 46 105.05 0.313
WSH 111 51 0.395 23.341 97 105 99.55 0.319

Build an Archetype Scorecard

Raw metrics have different scales, so a quick within-sample rescale makes the profiles easier to compare. A score of 1 means best among these five teams; a score of 0 means lowest among these five teams.

# Rescale profile metrics within sample.
rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE)
  if (!all(is.finite(rng)) || diff(rng) == 0) {
    return(rep(NA_real_, length(x)))
  }
  (x - rng[1]) / diff(rng)
}
scorecard <- data.frame(
  team = team_profiles[['team']],
  territory = rescale01(team_profiles[['offensiveZonePctg']]),
  pace = rescale01(team_profiles[['burstsOver22']]),
  shotPower = rescale01(team_profiles[['shotAttemptsOver90']]),
  interior = rescale01(team_profiles[['interiorShare']]),
  hardestShot = rescale01(team_profiles[['hardestShot']]),
  stringsAsFactors = FALSE
)
make_table(
  scorecard,
  caption = 'Within-sample EDGE archetype scores.',
  digits = 3
)
Within-sample EDGE archetype scores.
team territory pace shotPower interior hardestShot
CAR 1.000 0.305 0.211 0.312 0.259
COL 0.460 1.000 0.078 0.000 0.000
EDM 0.523 0.768 1.000 1.000 0.081
FLA 0.670 0.000 0.000 0.258 1.000
WSH 0.000 0.299 0.656 0.393 0.170
# Plot archetype scorecard.
score_matrix <- t(as.matrix(scorecard[, -1]))
colnames(score_matrix) <- scorecard[['team']]
graphics::barplot(
  score_matrix,
  beside = TRUE,
  col = c('#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'),
  ylim = c(0, 1.35),
  ylab = 'Within-Sample Score',
  xlab = 'Team'
)
graphics::legend(
  'top',
  legend = rownames(score_matrix),
  fill = c('#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'),
  bty = 'n',
  cex = 0.75,
  ncol = 3
)
Within-sample archetype scorecard for five teams.

Within-sample archetype scorecard for five teams.

This is the article’s main picture. Carolina’s offense looks territorial. Colorado’s looks fast. Florida’s profile carries shot-power and interior pressure. Edmonton brings the dangerous-volume flavor. Washington is the useful reminder that winning teams do not have to max out every tracking trait.

Shot Geography

Shot mix gives the scorecard some texture. Interior share is not the same thing as total offense; it is the shape of the tracked attempts.

# Plot shot-location mix.
shot_mix <- t(as.matrix(team_profiles[, c(
  'interiorShare',
  'circleShare',
  'pointShare'
)]))
colnames(shot_mix) <- team_profiles[['team']]
rownames(shot_mix) <- c('Interior', 'Circles / slot', 'Points / perimeter')
graphics::barplot(
  shot_mix,
  beside = FALSE,
  col = c('#1b4332', '#52b788', '#b7e4c7'),
  ylim = c(0, 1.18),
  ylab = 'Share of Tracked Shots',
  xlab = 'Team'
)
graphics::legend(
  'top',
  legend = rownames(shot_mix),
  fill = c('#1b4332', '#52b788', '#b7e4c7'),
  bty = 'n',
  cex = 0.8,
  horiz = TRUE
)
Tracked shot-location mix by team.

Tracked shot-location mix by team.

The shot mix keeps the style comparison grounded. A team can be fast without living at the net front. A team can own territory without having the hardest shot profile. The categories overlap, but they do not collapse into one generic “good offense” number.

Put Names Back on Traits

Tracking data become more interesting when team traits are tied back to players.

# Show players behind extreme traits.
player_table <- team_profiles[, c(
  'team',
  'fastestSkater',
  'maxSkatingSpeed',
  'hardestShooter',
  'hardestShot'
)]
make_table(
  player_table,
  caption = 'Players behind each team profile.'
)
Players behind each team profile.
team fastestSkater maxSkatingSpeed hardestShooter hardestShot
CAR Martin Necas 24.491 Dmitry Orlov 100.14
COL Miles Wood 24.817 Cale Makar 98.42
EDM Mattias Janmark 24.359 Viktor Arvidsson 98.96
FLA Carter Verhaeghe 23.415 Gustav Forsling 105.05
WSH Martin Fehérváry 23.341 John Carlson 99.55

This is why the EDGE wrappers are useful beyond leaderboards. They let you move from team identity to the players who create that identity without leaving R.

What We Learned

Elite offense is plural. Carolina can look elite through zone-time dominance. Colorado can look elite through pace. Florida can look elite through force and interior pressure. Edmonton can look elite through dangerous-volume traits. Washington can win enough to belong in the conversation while reminding us that the cleanest tracking profile is not the only path to results.

That is the broader nhlscraper lesson: the NHL EDGE endpoints are not just novel leaderboards. They are style data. Once scraped into tables, they let you ask whether teams that land near each other in the standings actually play the same sport in the same way.