Hockey Age Curves and Peak Performance

Beginner 10 min read 20 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.