
Do Elite Teams Really All Play the Same Way?
Source:vignettes/team-edge-archetypes.Rmd
team-edge-archetypes.RmdOverview
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:
-
CARfor territorial control. -
COLfor speed. -
EDMfor dangerous volume. -
FLAfor force and shot power. -
WSHas 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.'
)| 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.'
)| 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.
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.
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."
)| 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.