Skip to contents

Overview

It is easy to talk about “elite teams” as if they all converge on the same style. In practice, they usually do not. Some clubs choke the game with zone time, some win with speed, some pile up dangerous volume, and some lean on shot power and finishing talent. That is exactly the kind of question the NHL EDGE wrappers in nhlscraper are built for. This example asks: if we compare several top 2024-25 teams, do they actually arrive at offense the same way? We will use nhlscraper::team_edge_leaders() to set the league-wide scene, then a set of team-level EDGE endpoints to compare five excellent 2024-25 clubs:

  • CAR for territorial control.
  • COL for speed.
  • EDM for dangerous volume.
  • FLA for force and shot power.
  • WSH as a strong regular-season counterexample that was not built around pure territorial dominance.

The goal is not to crown one style as best. The goal is to show how different contender identities become visible once tracking data is easy to scrape.

Start With League Leaders

Before focusing on the five-team comparison, it helps to see which clubs topped the headline EDGE categories in the 2024-25 regular season.

# Pull 2024-25 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 = 'League-leading 2024-25 team EDGE categories.'
)
League-leading 2024-25 team EDGE categories.
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 board already tells an important story: one team does not own everything. Carolina owns territorial control, Colorado dominates speed, and Edmonton wins the dangerous-volume categories. That is the setup for a richer comparison.

Build Five-Team Profiles

We will build the five-team comparison from targeted EDGE endpoints so the article is more resilient when one nested response comes back incomplete.

# Define selected teams and robust fetch helpers.
team_ids <- c(CAR = 12, COL = 21, EDM = 22, FLA = 13, WSH = 15)
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))
}
extract_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)
}
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) && 'team' %in% names(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 = if (is.null(team_summary)) NA_real_ else as.numeric(team_summary[['team']][['points']]),
      wins = if (is.null(team_summary)) NA_real_ else as.numeric(team_summary[['team']][['wins']]),
      offensiveZonePctg = NA_real_,
      maxSkatingSpeed = NA_real_,
      burstsOver22 = NA_real_,
      shotAttemptsOver90 = NA_real_,
      hardestShot = NA_real_,
      interiorShare = NA_real_,
      circleShare = NA_real_,
      pointShare = NA_real_,
      otherShare = 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']])
  data.frame(
    team = team_code,
    points = if (is.null(team_summary)) NA_real_ else as.numeric(team_summary[['team']][['points']]),
    wins = if (is.null(team_summary)) NA_real_ else as.numeric(team_summary[['team']][['wins']]),
    offensiveZonePctg = as.numeric(zone_row[['offensiveZonePctg']][1]),
    maxSkatingSpeed = as.numeric(skating_row[['maxSkatingSpeed.imperial']][1]),
    burstsOver22 = as.numeric(skating_row[['burstsOver22.value']][1]),
    shotAttemptsOver90 = as.numeric(
      shot_speed_row[['shotAttemptsOver100.value']][1] +
        shot_speed_row[['shotAttempts90To100.value']][1]
    ),
    hardestShot = as.numeric(shot_speed_row[['topShotSpeed.imperial']][1]),
    interiorShare = sum(shot_location_rows[['sog']][interior_mask]) / total_shots,
    circleShare = sum(shot_location_rows[['sog']][circle_mask]) / total_shots,
    pointShare = sum(shot_location_rows[['sog']][point_mask]) / total_shots,
    otherShare = sum(shot_location_rows[['sog']][!(interior_mask | circle_mask | point_mask)]) / total_shots,
    fastestSkater = extract_name(
      skating_row[['maxSkatingSpeed.overlay.player.firstName.default']][1],
      skating_row[['maxSkatingSpeed.overlay.player.lastName.default']][1]
    ),
    hardestShooter = extract_name(
      shot_speed_row[['topShotSpeed.overlay.player.firstName.default']][1],
      shot_speed_row[['topShotSpeed.overlay.player.lastName.default']][1]
    ),
    stringsAsFactors = FALSE
  )
}
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.'
)
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

This table is exactly why EDGE data are fun. Carolina owns the strongest offensive-zone share in the group. Colorado is the speed monster. Edmonton is the dangerous-volume machine. Florida has the loudest single-shot power in the set. Washington, meanwhile, wins a lot without looking like the cleanest territorial team on the page. That is already enough to reject the idea that there is one universal blueprint for elite team offense.

Compare Possession and Pace

The fastest way to see those archetypes is to put territorial control beside pure pace.

# Plot territorial control and burst volume.
old_par <- graphics::par(no.readonly = TRUE)
graphics::par(mfrow = c(1, 2), mar = c(5, 7, 3, 1))
ordered_zone <- team_profiles[order(team_profiles[['offensiveZonePctg']]), ]
graphics::barplot(
  ordered_zone[['offensiveZonePctg']],
  names.arg = ordered_zone[['team']],
  horiz = TRUE,
  las = 1,
  col = '#2a9d8f',
  border = NA,
  xlab = 'Offensive-Zone Share'
)
ordered_bursts <- team_profiles[order(team_profiles[['burstsOver22']]), ]
graphics::barplot(
  ordered_bursts[['burstsOver22']],
  names.arg = ordered_bursts[['team']],
  horiz = TRUE,
  las = 1,
  col = '#e76f51',
  border = NA,
  xlab = 'Bursts Over 22 MPH'
)
Territorial control and pace look different across elite 2024-25 teams.

Territorial control and pace look different across elite 2024-25 teams.

graphics::par(old_par)

Carolina and Colorado are both excellent teams, but they do not get there in the same way. Carolina’s profile screams territorial pressure. Colorado’s profile screams pace. Edmonton leans closer to Colorado on velocity, but its dangerous-volume numbers make the attack feel more direct and shot-driven.

Compare Shot Geography

The shot mix tells a second story. Some teams live at the net front. Others attack more often from circles and points before crashing inside.

# Plot shot mix shares.
shot_mix <- t(as.matrix(team_profiles[, c(
  'interiorShare',
  'circleShare',
  'pointShare',
  'otherShare'
)]))
colnames(shot_mix) <- team_profiles[['team']]
rownames(shot_mix) <- c(
  'Interior',
  'Circles and slot',
  'Points and perimeter',
  'Other'
)
graphics::barplot(
  shot_mix,
  beside = FALSE,
  col = c('#1b4332', '#40916c', '#74c69d', '#d8f3dc'),
  ylim = c(0, 1),
  ylab = 'Share of Tracked Shots',
  xlab = 'Team'
)
graphics::legend(
  'topright',
  legend = rownames(shot_mix),
  fill = c('#1b4332', '#40916c', '#74c69d', '#d8f3dc'),
  bty = 'n'
)
Shot-geography mix for five elite 2024-25 teams.

Shot-geography mix for five elite 2024-25 teams.

This plot sharpens the style discussion. Edmonton and Florida push a larger share of tracked shots toward the interior. Carolina keeps a broader attack map while still controlling territory. Washington’s shot mix helps explain how a team can win plenty without leading the pure pace categories.

Put Player Names on Team Traits

Another strength of the EDGE summaries is that they keep the most extreme team traits connected to the players who generated them.

# Show players behind each team's most extreme speed and shot events.
player_table <- team_profiles[, c(
  'team',
  'fastestSkater',
  'maxSkatingSpeed',
  'hardestShooter',
  'hardestShot'
)]
make_table(
  player_table,
  caption = "Players behind each team's fastest burst and hardest shot."
)
Players behind each team’s fastest burst and hardest shot.
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

That detail keeps the analysis from drifting into abstraction. A team style is still built by players. Colorado’s pace story lives inside names like Miles Wood and Cale Makar. Florida’s shot-power identity lives in specific shooters. The tracking layer works best when it keeps those levels connected.

What We Learned

Elite teams do not all play the same way. In this 2024-25 EDGE sample, Carolina looks like the territorial heavyweight, Colorado looks like the pace team, Edmonton looks like the dangerous-volume attack, and Florida and Washington bring their own different blends of interior pressure and shot power. That makes the NHL EDGE wrappers especially valuable example material, because they let you move from generic talk about “style” to a direct, scrapeable comparison of how different good teams actually manufacture offense.