Hockey Age Curves and Peak Performance

Beginner 10 min read 0 views Nov 27, 2025

When Do Hockey Players Peak?

Understanding age curves—how player performance changes with age—is crucial for contract negotiations, roster planning, and long-term team building. Different positions peak at different ages, and modern analytics can quantify these patterns precisely.

Typical Peak Ages by Position

  • Forwards: Age 24-27 (offensive peak around 25)
  • Defensemen: Age 26-29 (longer prime window)
  • Goalies: Age 28-31 (latest peak)
  • Decline Phase: Begins around age 30-32 for most players

Python: Calculate Age Curves

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import UnivariateSpline

# Load historical player data
player_careers = pd.read_csv('nhl_player_career_stats.csv')

# Calculate age curves by position
positions = ['C', 'LW', 'RW', 'D']

def calculate_age_curve(position_data):
    """Calculate average performance by age"""
    age_performance = position_data.groupby('age').agg({
        'points_per_game': 'mean',
        'goals_per_game': 'mean',
        'assists_per_game': 'mean',
        'plus_minus_per_game': 'mean'
    }).reset_index()

    return age_performance

age_curves = {}
for position in positions:
    pos_data = player_careers[player_careers['position'] == position]
    age_curves[position] = calculate_age_curve(pos_data)

# Identify peak age for each position
print("=== Peak Performance Age by Position ===")
for position, curve_data in age_curves.items():
    peak_age = curve_data.loc[
        curve_data['points_per_game'].idxmax(), 'age'
    ]
    peak_ppg = curve_data['points_per_game'].max()
    print(f"{position}: Age {peak_age:.0f} ({peak_ppg:.3f} PPG)")

# Calculate aging factors (year-over-year decline)
def calculate_aging_factor(position_data):
    """Calculate expected performance change by age"""
    aging_factors = []

    for age in range(20, 38):
        current_age = position_data[position_data['age'] == age]
        next_age = position_data[position_data['age'] == age + 1]

        if len(current_age) > 0 and len(next_age) > 0:
            current_ppg = current_age['points_per_game'].mean()
            next_ppg = next_age['points_per_game'].mean()

            factor = (next_ppg / current_ppg) - 1
            aging_factors.append({
                'age': age,
                'aging_factor': factor
            })

    return pd.DataFrame(aging_factors)

print("\n=== Aging Factors (Year-over-Year Change) ===")
for position in positions:
    pos_data = player_careers[player_careers['position'] == position]
    factors = calculate_aging_factor(pos_data)
    print(f"\n{position}:")
    print(factors)

# Project future performance for a player
def project_performance(current_age, current_ppg, position, years_ahead=5):
    """Project future performance based on age curve"""
    pos_data = player_careers[player_careers['position'] == position]
    factors = calculate_aging_factor(pos_data)

    projections = [current_ppg]
    for year in range(1, years_ahead + 1):
        age = current_age + year

        factor_row = factors[factors['age'] == age]
        if len(factor_row) > 0:
            aging_factor = factor_row['aging_factor'].values[0]
            next_ppg = projections[-1] * (1 + aging_factor)
            projections.append(max(0, next_ppg))  # Can't be negative
        else:
            # Default decline for ages beyond data
            projections.append(projections[-1] * 0.95)

    return projections

# Example: Project a 27-year-old center's performance
player_projection = project_performance(
    current_age=27,
    current_ppg=0.95,
    position='C',
    years_ahead=5
)

print("\n=== Player Projection (27-year-old Center, 0.95 PPG) ===")
for i, ppg in enumerate(player_projection):
    age = 27 + i
    print(f"Age {age}: {ppg:.3f} PPG")

# Contract value analysis based on age
def calculate_expected_war(age, current_ppg, position, contract_years):
    """Calculate expected Wins Above Replacement over contract"""
    projections = project_performance(age, current_ppg, position, contract_years)

    # Rough WAR estimation: 1 PPG ≈ 3 WAR (simplified)
    war_values = [ppg * 3 for ppg in projections[1:]]  # Exclude current year
    total_war = sum(war_values)

    return total_war, war_values

# Example contract analysis
age, ppg, position, years = 28, 0.85, 'D', 6
total_war, war_by_year = calculate_expected_war(age, ppg, position, years)

print(f"\n=== Contract Analysis: {years}-year deal for {age}-year-old {position} ===")
print(f"Current Production: {ppg:.3f} PPG")
print(f"Expected Total WAR: {total_war:.1f}")
print("WAR by Contract Year:", [f"{w:.2f}" for w in war_by_year])

R: Age Curve Visualization

library(tidyverse)
library(splines)
library(scales)

# Load career statistics
player_careers <- read_csv("nhl_player_career_stats.csv")

# Calculate age curves by position
positions <- c("C", "LW", "RW", "D")

age_curves <- positions %>%
  map_dfr(function(pos) {
    player_careers %>%
      filter(position == pos) %>%
      group_by(age) %>%
      summarise(
        avg_ppg = mean(points_per_game, na.rm = TRUE),
        avg_goals = mean(goals_per_game, na.rm = TRUE),
        avg_assists = mean(assists_per_game, na.rm = TRUE),
        n_seasons = n(),
        .groups = "drop"
      ) %>%
      mutate(position = pos)
  })

# Identify peak age for each position
peak_ages <- age_curves %>%
  group_by(position) %>%
  slice_max(avg_ppg, n = 1) %>%
  select(position, peak_age = age, peak_ppg = avg_ppg)

cat("=== Peak Performance Age by Position ===\n")
print(peak_ages)

# Calculate aging factors
calculate_aging_factors <- function(position_data) {
  position_data %>%
    arrange(age) %>%
    mutate(
      next_ppg = lead(avg_ppg),
      aging_factor = (next_ppg / avg_ppg) - 1
    ) %>%
    filter(!is.na(aging_factor))
}

aging_factors <- age_curves %>%
  group_by(position) %>%
  group_modify(~calculate_aging_factors(.x)) %>%
  ungroup()

cat("\n=== Aging Factors by Position ===\n")
print(aging_factors %>%
  filter(age >= 20, age <= 35) %>%
  select(position, age, avg_ppg, aging_factor))

# Function to project future performance
project_performance <- function(current_age, current_ppg, position,
                               years_ahead = 5, factors_df) {
  projections <- numeric(years_ahead + 1)
  projections[1] <- current_ppg

  for (i in 1:years_ahead) {
    age <- current_age + i

    factor <- factors_df %>%
      filter(position == !!position, age == !!age) %>%
      pull(aging_factor)

    if (length(factor) > 0) {
      projections[i + 1] <- projections[i] * (1 + factor)
    } else {
      # Default decline
      projections[i + 1] <- projections[i] * 0.95
    }

    projections[i + 1] <- max(0, projections[i + 1])
  }

  return(projections)
}

# Example projection
player_projection <- tibble(
  age = 27:32,
  projected_ppg = project_performance(27, 0.95, "C", 5, aging_factors)
)

cat("\n=== Player Projection (27-year-old Center, 0.95 PPG) ===\n")
print(player_projection)

# Visualize age curves
ggplot(age_curves %>% filter(age >= 18, age <= 40),
       aes(x = age, y = avg_ppg, color = position)) +
  geom_line(size = 1.2) +
  geom_point(size = 2) +
  geom_vline(data = peak_ages, aes(xintercept = peak_age, color = position),
             linetype = "dashed", alpha = 0.5) +
  scale_y_continuous(labels = number_format(accuracy = 0.01)) +
  labs(title = "Hockey Age Curves by Position",
       subtitle = "Average points per game across career ages",
       x = "Age", y = "Points Per Game",
       color = "Position") +
  theme_minimal() +
  theme(legend.position = "bottom")

# Contract value analysis
calculate_expected_war <- function(age, current_ppg, position,
                                  contract_years, factors_df) {
  projections <- project_performance(age, current_ppg, position,
                                    contract_years, factors_df)

  # PPG to WAR conversion (simplified: 1 PPG ≈ 3 WAR)
  war_values <- projections[-1] * 3

  tibble(
    contract_year = 1:contract_years,
    age = (age + 1):(age + contract_years),
    projected_ppg = projections[-1],
    projected_war = war_values
  )
}

# Example contract analysis
contract_analysis <- calculate_expected_war(28, 0.85, "D", 6, aging_factors)

cat("\n=== Contract Analysis: 6-year deal for 28-year-old D ===\n")
print(contract_analysis)
cat(sprintf("\nTotal Expected WAR: %.1f\n", sum(contract_analysis$projected_war)))

Application to Contract Decisions

Age curves are critical for long-term contract negotiations. A 6-year deal for a 28-year-old means paying for ages 29-34, which typically includes both prime years and decline years. Teams must weigh current performance against projected aging when determining contract length and value.

Contract Considerations

  • Long-term deals for players 30+ carry significant risk of decline
  • Defensemen age more gracefully than forwards
  • Power play specialists may see sharper declines than even-strength players
  • Individual variation exists—some players defy typical age curves

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.