Chapter 22 Intermediate ~50 min read

Speed, Distance, and Movement Analysis

Using tracking data to analyze player movement, speed patterns, and the physical demands of basketball.

Quantifying Player Movement

Speed and distance tracking quantifies the physical dimension of basketball performance that traditional statistics completely miss. Before tracking data, we knew some players worked harder than others off the ball, but we could only observe this qualitatively. Now we can measure precisely how far each player travels per game, how fast they move in different contexts, and how their movement patterns contribute to team success.

This data reveals that basketball's physical demands vary enormously by position, role, and playing style. Some players routinely cover over three miles per game while others barely reach two. Average speeds range from walking pace for post players to near-sprinting for perimeter defenders.

Distance and Speed Metrics

The primary tracking metrics include total distance traveled, average speed, and breakdowns by context. Distance per game correlates with playing time, so per-minute rates enable fairer comparisons. The league average hovers around 0.08 miles per minute.

Guards typically cover more ground than big men, reflecting their responsibilities to chase through screens on defense and move off ball on offense.

import pandas as pd
import matplotlib.pyplot as plt

def analyze_movement_patterns(tracking_df):
    """Analyze player movement patterns from tracking data"""
    tracking_df['DIST_PER_MIN'] = tracking_df['DIST_MILES'] / tracking_df['MIN']
    tracking_df['OFF_DEF_RATIO'] = (tracking_df['DIST_MILES_OFF'] /
                                      tracking_df['DIST_MILES_DEF'])

    position_stats = tracking_df.groupby('POSITION').agg({
        'DIST_PER_MIN': 'mean',
        'AVG_SPEED': 'mean',
        'OFF_DEF_RATIO': 'mean'
    }).round(3)

    return position_stats

Offensive vs. Defensive Movement

Tracking separates movement into offensive and defensive possessions, revealing how players allocate effort across phases. Defensive movement tends to be more intense because defensive positioning requires constant reaction to offensive actions.

Elite off-ball movers on offense show higher offensive movement than typical for their positions. Their constant motion to get open or create confusion forces defensive rotations and generates open shots.

Movement and Fatigue

Speed and distance data inform understanding of fatigue effects. Players who cover more ground early in games might show performance degradation later. High-minute players accumulate physical stress that could affect late-season or playoff performance.

Load management strategies increasingly incorporate tracking data alongside traditional minutes counts. A player who covered 3.2 miles in 30 minutes might need more recovery than one who covered 2.6 miles in the same time.

Movement Quality vs. Quantity

Raw distance doesn't fully capture movement value. A player who runs in circles covers distance without purpose, while a well-timed cut might only be a few steps but creates a scoring opportunity. Effective movement can be identified by connecting movement patterns to shot creation.

Implementation in R

# Calculate player distance metrics
library(tidyverse)

calculate_distance_metrics <- function(tracking_data) {
  tracking_data %>%
    group_by(player_id, game_id) %>%
    summarise(
      # Total distance covered
      total_distance_ft = sum(distance, na.rm = TRUE),
      total_distance_miles = total_distance_ft / 5280,

      # Distance by speed category
      walk_distance = sum(distance[speed_mph < 3], na.rm = TRUE),
      jog_distance = sum(distance[speed_mph >= 3 & speed_mph < 8], na.rm = TRUE),
      run_distance = sum(distance[speed_mph >= 8 & speed_mph < 14], na.rm = TRUE),
      sprint_distance = sum(distance[speed_mph >= 14], na.rm = TRUE),

      # Minutes played
      minutes = n() * 0.04 / 60,

      .groups = "drop"
    ) %>%
    mutate(
      miles_per_minute = round(total_distance_miles / minutes, 3),
      sprint_pct = round(sprint_distance / total_distance_ft * 100, 1)
    )
}

tracking <- read_csv("player_tracking_with_speed.csv")
distance_metrics <- calculate_distance_metrics(tracking)

# Top distance players
top_distance <- distance_metrics %>%
  arrange(desc(total_distance_miles)) %>%
  select(player_name, total_distance_miles, sprint_pct, miles_per_minute) %>%
  head(15)

print(top_distance)
# Analyze offensive vs defensive movement
library(tidyverse)

analyze_movement_phases <- function(tracking_data) {
  tracking_data %>%
    group_by(player_id, possession_type) %>%
    summarise(
      possessions = n_distinct(possession_id),
      total_distance = sum(distance, na.rm = TRUE) / 5280,
      avg_speed = mean(speed_mph, na.rm = TRUE),
      sprint_distance = sum(distance[speed_mph >= 14], na.rm = TRUE) / 5280,
      .groups = "drop"
    ) %>%
    pivot_wider(
      names_from = possession_type,
      values_from = c(total_distance, avg_speed, sprint_distance),
      names_sep = "_"
    )
}

phase_analysis <- analyze_movement_phases(tracking)

# Compare offensive vs defensive effort
comparison <- phase_analysis %>%
  mutate(
    off_def_distance_ratio = total_distance_offense / total_distance_defense,
    off_def_sprint_ratio = sprint_distance_offense / sprint_distance_defense
  ) %>%
  arrange(desc(off_def_sprint_ratio))

print(comparison)

Implementation in R

# Calculate player distance metrics
library(tidyverse)

calculate_distance_metrics <- function(tracking_data) {
  tracking_data %>%
    group_by(player_id, game_id) %>%
    summarise(
      # Total distance covered
      total_distance_ft = sum(distance, na.rm = TRUE),
      total_distance_miles = total_distance_ft / 5280,

      # Distance by speed category
      walk_distance = sum(distance[speed_mph < 3], na.rm = TRUE),
      jog_distance = sum(distance[speed_mph >= 3 & speed_mph < 8], na.rm = TRUE),
      run_distance = sum(distance[speed_mph >= 8 & speed_mph < 14], na.rm = TRUE),
      sprint_distance = sum(distance[speed_mph >= 14], na.rm = TRUE),

      # Minutes played
      minutes = n() * 0.04 / 60,

      .groups = "drop"
    ) %>%
    mutate(
      miles_per_minute = round(total_distance_miles / minutes, 3),
      sprint_pct = round(sprint_distance / total_distance_ft * 100, 1)
    )
}

tracking <- read_csv("player_tracking_with_speed.csv")
distance_metrics <- calculate_distance_metrics(tracking)

# Top distance players
top_distance <- distance_metrics %>%
  arrange(desc(total_distance_miles)) %>%
  select(player_name, total_distance_miles, sprint_pct, miles_per_minute) %>%
  head(15)

print(top_distance)
# Analyze offensive vs defensive movement
library(tidyverse)

analyze_movement_phases <- function(tracking_data) {
  tracking_data %>%
    group_by(player_id, possession_type) %>%
    summarise(
      possessions = n_distinct(possession_id),
      total_distance = sum(distance, na.rm = TRUE) / 5280,
      avg_speed = mean(speed_mph, na.rm = TRUE),
      sprint_distance = sum(distance[speed_mph >= 14], na.rm = TRUE) / 5280,
      .groups = "drop"
    ) %>%
    pivot_wider(
      names_from = possession_type,
      values_from = c(total_distance, avg_speed, sprint_distance),
      names_sep = "_"
    )
}

phase_analysis <- analyze_movement_phases(tracking)

# Compare offensive vs defensive effort
comparison <- phase_analysis %>%
  mutate(
    off_def_distance_ratio = total_distance_offense / total_distance_defense,
    off_def_sprint_ratio = sprint_distance_offense / sprint_distance_defense
  ) %>%
  arrange(desc(off_def_sprint_ratio))

print(comparison)
Chapter Summary

You've completed Chapter 22: Speed, Distance, and Movement Analysis.