Play-by-play data represents the fundamental building block of football analytics. Every snap generates a data point that captures the game state, the action taken, and the resulting change in field position and scoring expectancy. Visualizing this...
In This Chapter
- Introduction
- 13.1 The Structure of Play-by-Play Data
- 13.2 Drive Charts
- 13.3 Win Probability Visualizations
- 13.4 Play-Level Performance Visualization
- 13.5 Sequential Play Visualization
- 13.6 Animated Visualizations
- 13.7 Case Study: Visualizing a Championship Game
- Summary
- Key Concepts
- Practice Exercises
Chapter 13: Play-by-Play Visualization
Introduction
Play-by-play data represents the fundamental building block of football analytics. Every snap generates a data point that captures the game state, the action taken, and the resulting change in field position and scoring expectancy. Visualizing this granular data effectively transforms raw play sequences into compelling narratives that reveal patterns invisible in traditional box scores.
This chapter explores techniques for visualizing play-level data, from single-drive charts to season-long efficiency breakdowns. You'll learn to create visualizations that show not just what happened, but why it mattered—connecting each play to its impact on game outcomes.
Learning Objectives
After completing this chapter, you will be able to:
- Create drive charts that show the flow of a single possession
- Build win probability curves that capture game momentum
- Visualize play-by-play EPA to identify key moments
- Design field visualizations showing play locations and outcomes
- Construct animated sequences that bring static data to life
- Apply these techniques to game analysis and coaching presentations
13.1 The Structure of Play-by-Play Data
Understanding the Data Model
Play-by-play data captures a snapshot of each play with fields that describe the game state before the play, the action taken, and the resulting game state:
play_data = {
# Pre-play state
'game_id': 'GAME_2024_001',
'drive_id': 5,
'play_number': 47,
'quarter': 3,
'time': '8:42',
'down': 2,
'distance': 7,
'yard_line': 65, # 1-99 scale
'score_diff': -7,
# Play action
'play_type': 'pass',
'play_description': '15-yard completion to WR #82',
# Post-play state
'yards_gained': 15,
'yard_line_end': 80,
'down_after': 1,
'distance_after': 10,
'result': 'first_down',
# Advanced metrics
'ep_before': 2.45,
'ep_after': 3.72,
'epa': 1.27,
'wp_before': 0.38,
'wp_after': 0.44,
'wpa': 0.06
}
Key Visualization Variables
When visualizing play-by-play data, we work with several key dimensions:
Temporal Dimensions: - Game time (quarters, minutes, seconds) - Play sequence within drives - Season week for longitudinal analysis
Spatial Dimensions: - Yard line (field position) - Down and distance - Play direction (if tracking data available)
Performance Dimensions: - EPA (Expected Points Added) - WPA (Win Probability Added) - Success rate (binary outcome) - Yards gained
Categorical Dimensions: - Play type (pass, rush, special teams) - Personnel groupings - Formation types
13.2 Drive Charts
The Traditional Drive Chart
Drive charts provide a visual summary of offensive possessions, showing how the team moved (or didn't move) down the field. They're essential for understanding game flow and identifying momentum shifts.
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
from typing import List, Dict
class DriveChartVisualizer:
"""Create visual representations of offensive drives."""
def __init__(self):
self.colors = {
'touchdown': '#2a9d8f',
'field_goal': '#e9c46a',
'turnover': '#e76f51',
'punt': '#8d99ae',
'downs': '#e76f51',
'end_half': '#adb5bd',
'ongoing': '#264653'
}
self.field_green = '#2e5a1c'
self.yard_lines = '#ffffff'
def create_single_drive_chart(self,
plays: List[Dict],
title: str = "Drive Summary",
figsize: tuple = (14, 4)) -> plt.Figure:
"""
Create a detailed chart for a single drive.
Args:
plays: List of play dictionaries with yard_line, yards_gained, etc.
title: Chart title
figsize: Figure dimensions
"""
fig, ax = plt.subplots(figsize=figsize)
# Draw field
self._draw_field(ax)
# Plot plays
current_yl = plays[0]['yard_line']
for i, play in enumerate(plays):
start_yl = play['yard_line']
yards = play.get('yards_gained', 0)
end_yl = min(100, max(0, start_yl + yards))
# Determine play color
if play.get('touchdown'):
color = self.colors['touchdown']
marker = 'o'
elif play.get('turnover'):
color = self.colors['turnover']
marker = 'x'
elif yards >= 10:
color = self.colors['touchdown'] # Explosive play
marker = '^'
elif yards > 0:
color = '#4a7ca8' # Positive play
marker = 'o'
else:
color = self.colors['turnover'] # Negative/no gain
marker = 'v'
# Draw play arrow
ax.annotate('', xy=(end_yl, 0.5), xytext=(start_yl, 0.5),
arrowprops=dict(arrowstyle='->', color=color,
lw=2, mutation_scale=15))
# Play marker
ax.scatter(start_yl, 0.5, s=100, c=color, marker=marker, zorder=5)
# Play label
label = f"{play.get('down', '?')}&{play.get('distance', '?')}"
ax.text(start_yl, 0.7, label, ha='center', fontsize=8, rotation=45)
# Drive result marker
final_play = plays[-1]
result = final_play.get('drive_result', 'unknown')
result_x = final_play.get('yard_line', 50) + final_play.get('yards_gained', 0)
result_x = min(100, max(0, result_x))
ax.scatter(result_x, 0.5, s=200, c=self.colors.get(result, 'gray'),
marker='s', zorder=10, edgecolors='white', linewidths=2)
# Title and labels
ax.set_title(title, fontsize=14, fontweight='bold', pad=10)
ax.set_xlim(-5, 105)
ax.set_ylim(0, 1)
ax.axis('off')
return fig
def _draw_field(self, ax):
"""Draw football field background."""
# Green field
field = patches.Rectangle((0, 0), 100, 1, facecolor=self.field_green,
edgecolor='white', linewidth=2)
ax.add_patch(field)
# Yard lines
for yl in range(0, 101, 10):
ax.axvline(yl, color=self.yard_lines, linewidth=0.5, alpha=0.5)
# End zones
ax.axvline(0, color='white', linewidth=2)
ax.axvline(100, color='white', linewidth=2)
# Yard markers
for yl in [10, 20, 30, 40, 50, 60, 70, 80, 90]:
display_yl = yl if yl <= 50 else 100 - yl
ax.text(yl, 0.1, str(display_yl), ha='center', fontsize=8,
color='white', alpha=0.7)
def create_game_drive_summary(self,
drives: List[List[Dict]],
team_name: str,
figsize: tuple = (14, 10)) -> plt.Figure:
"""
Create summary of all drives in a game.
Args:
drives: List of drives, each containing list of plays
team_name: Name of team for title
figsize: Figure dimensions
"""
n_drives = len(drives)
fig, axes = plt.subplots(n_drives, 1, figsize=figsize)
if n_drives == 1:
axes = [axes]
for i, (ax, drive) in enumerate(zip(axes, drives)):
# Draw field
self._draw_field(ax)
# Starting position
start_yl = drive[0]['yard_line']
ax.scatter(start_yl, 0.5, s=100, c='white', marker='o',
zorder=5, edgecolors='black')
# Plot each play as segment
current_yl = start_yl
for play in drive:
yards = play.get('yards_gained', 0)
next_yl = min(100, max(0, current_yl + yards))
# Color by gain
if yards >= 15:
color = '#2a9d8f'
elif yards >= 4:
color = '#4a7ca8'
elif yards > 0:
color = '#6c757d'
else:
color = '#e76f51'
ax.plot([current_yl, next_yl], [0.5, 0.5], color=color,
linewidth=4, solid_capstyle='round')
current_yl = next_yl
# Drive result
result = drive[-1].get('drive_result', 'unknown')
result_color = self.colors.get(result, 'gray')
ax.scatter(current_yl, 0.5, s=150, c=result_color, marker='s',
zorder=10, edgecolors='white', linewidths=2)
# Drive label
total_yards = sum(p.get('yards_gained', 0) for p in drive)
plays_count = len(drive)
ax.text(105, 0.5, f"D{i+1}: {plays_count}p, {total_yards}yds",
ha='left', va='center', fontsize=9)
ax.set_xlim(-5, 130)
ax.set_ylim(0, 1)
ax.axis('off')
fig.suptitle(f'{team_name} - Drive Summary', fontsize=14,
fontweight='bold', y=0.98)
plt.tight_layout()
return fig
Modern Drive Visualization with EPA
Traditional drive charts show yards gained, but EPA-annotated drives reveal the true value of each play:
class EPADriveChart:
"""Drive chart with EPA annotations."""
def __init__(self):
self.colors = {
'positive_big': '#1a9641',
'positive': '#a6d96a',
'neutral': '#ffffbf',
'negative': '#fdae61',
'negative_big': '#d7191c'
}
def create_epa_drive(self,
plays: List[Dict],
title: str = "Drive EPA Analysis",
figsize: tuple = (12, 6)) -> plt.Figure:
"""Create drive chart with EPA coloring and cumulative total."""
fig, (ax_drive, ax_epa) = plt.subplots(2, 1, figsize=figsize,
height_ratios=[1, 1],
sharex=True)
# Top: Drive progression
self._draw_field_horizontal(ax_drive)
current_yl = plays[0]['yard_line']
for i, play in enumerate(plays):
yards = play.get('yards_gained', 0)
epa = play.get('epa', 0)
# Color by EPA
color = self._epa_color(epa)
# Draw segment
next_yl = min(100, max(0, current_yl + yards))
ax_drive.plot([current_yl, next_yl], [0.5, 0.5],
color=color, linewidth=6, solid_capstyle='round')
# Play number marker
mid_x = (current_yl + next_yl) / 2
ax_drive.text(mid_x, 0.7, str(i+1), ha='center', fontsize=8,
color='white',
bbox=dict(boxstyle='circle', facecolor=color, alpha=0.8))
current_yl = next_yl
ax_drive.set_xlim(0, 100)
ax_drive.set_ylim(0, 1)
ax_drive.set_title(title, fontsize=14, fontweight='bold')
ax_drive.axis('off')
# Bottom: EPA bar chart
play_nums = range(1, len(plays) + 1)
epas = [p.get('epa', 0) for p in plays]
colors = [self._epa_color(e) for e in epas]
ax_epa.bar(play_nums, epas, color=colors, edgecolor='white')
ax_epa.axhline(0, color='black', linewidth=0.5)
# Cumulative line
cumulative = np.cumsum(epas)
ax_epa.plot(play_nums, cumulative, 'ko-', markersize=4,
linewidth=1.5, label='Cumulative EPA')
ax_epa.set_xlabel('Play Number')
ax_epa.set_ylabel('EPA')
ax_epa.legend(loc='upper left')
ax_epa.spines['top'].set_visible(False)
ax_epa.spines['right'].set_visible(False)
# Total EPA annotation
total_epa = sum(epas)
ax_epa.text(0.98, 0.95, f'Drive EPA: {total_epa:+.2f}',
ha='right', va='top', transform=ax_epa.transAxes,
fontsize=11, fontweight='bold',
bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
plt.tight_layout()
return fig
def _draw_field_horizontal(self, ax):
"""Draw simplified horizontal field."""
ax.axhspan(0, 1, facecolor='#2e5a1c', alpha=0.3)
for yl in range(0, 101, 10):
ax.axvline(yl, color='white', linewidth=0.5, alpha=0.3)
def _epa_color(self, epa: float) -> str:
"""Return color based on EPA value."""
if epa >= 1.0:
return self.colors['positive_big']
elif epa >= 0.3:
return self.colors['positive']
elif epa >= -0.3:
return self.colors['neutral']
elif epa >= -1.0:
return self.colors['negative']
else:
return self.colors['negative_big']
13.3 Win Probability Visualizations
The Win Probability Curve
Win probability (WP) visualization is one of the most powerful tools for showing game flow. It transforms a sequence of plays into a narrative arc that captures momentum, key moments, and turning points.
class WinProbabilityVisualizer:
"""Create win probability visualizations."""
def __init__(self):
self.home_color = '#2a9d8f'
self.away_color = '#e76f51'
def create_game_wp_chart(self,
plays: List[Dict],
home_team: str,
away_team: str,
key_moments: List[Dict] = None,
figsize: tuple = (14, 6)) -> plt.Figure:
"""
Create win probability chart for a full game.
Args:
plays: List of plays with wp_before, wp_after, quarter, time
home_team: Home team name
away_team: Away team name
key_moments: Optional list of moments to annotate
figsize: Figure dimensions
"""
fig, ax = plt.subplots(figsize=figsize)
# Convert plays to time-indexed WP
times = []
wps = []
for play in plays:
quarter = play.get('quarter', 1)
time_str = play.get('time', '15:00')
# Convert to game minutes
try:
mins, secs = map(int, time_str.split(':'))
game_min = (quarter - 1) * 15 + (15 - mins) - secs/60
except:
game_min = len(times) # Fallback
times.append(game_min)
wps.append(play.get('wp_after', 0.5))
# Add initial point
times = [0] + times
wps = [0.5] + wps
# Fill areas
ax.fill_between(times, wps, 0.5,
where=[wp >= 0.5 for wp in wps],
color=self.home_color, alpha=0.3, interpolate=True)
ax.fill_between(times, wps, 0.5,
where=[wp < 0.5 for wp in wps],
color=self.away_color, alpha=0.3, interpolate=True)
# Main line
ax.plot(times, wps, color='#264653', linewidth=2)
# Reference line
ax.axhline(0.5, color='gray', linestyle='--', linewidth=1, alpha=0.7)
# Quarter markers
for q_start in [15, 30, 45]:
ax.axvline(q_start, color='gray', linestyle=':', linewidth=0.5)
# Annotate key moments
if key_moments:
for moment in key_moments:
time_idx = moment.get('time_index', 0)
if time_idx < len(wps):
wp = wps[time_idx]
ax.scatter([times[time_idx]], [wp], s=100, color='#264653',
zorder=5, edgecolors='white', linewidths=2)
# Annotation
ax.annotate(
moment.get('description', ''),
xy=(times[time_idx], wp),
xytext=(10, 10 if wp > 0.5 else -10),
textcoords='offset points',
fontsize=8,
ha='left',
va='bottom' if wp > 0.5 else 'top',
bbox=dict(boxstyle='round', facecolor='white', alpha=0.8),
arrowprops=dict(arrowstyle='->', color='gray')
)
# Labels
ax.set_xlabel('Game Time (minutes)', fontsize=11)
ax.set_ylabel('Win Probability', fontsize=11)
ax.set_title(f'{home_team} vs {away_team} - Win Probability',
fontsize=14, fontweight='bold')
# Format y-axis as percentage
ax.set_ylim(0, 1)
ax.set_yticks([0, 0.25, 0.5, 0.75, 1.0])
ax.set_yticklabels(['0%', '25%', '50%', '75%', '100%'])
# Team labels
ax.text(0.02, 0.98, home_team, ha='left', va='top',
transform=ax.transAxes, fontsize=11, fontweight='bold',
color=self.home_color)
ax.text(0.02, 0.02, away_team, ha='left', va='bottom',
transform=ax.transAxes, fontsize=11, fontweight='bold',
color=self.away_color)
# Quarter labels
ax.set_xlim(0, 60)
ax.set_xticks([7.5, 22.5, 37.5, 52.5])
ax.set_xticklabels(['Q1', 'Q2', 'Q3', 'Q4'])
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
return fig
def create_wp_swing_chart(self,
plays: List[Dict],
top_n: int = 10,
figsize: tuple = (10, 6)) -> plt.Figure:
"""
Create chart showing biggest WP swings.
Args:
plays: List of plays with wpa calculated
top_n: Number of top swings to show
figsize: Figure dimensions
"""
# Calculate WPA for each play
wpas = [(i, p.get('wpa', 0), p.get('play_description', f'Play {i}'))
for i, p in enumerate(plays)]
# Sort by absolute WPA
wpas_sorted = sorted(wpas, key=lambda x: abs(x[1]), reverse=True)[:top_n]
fig, ax = plt.subplots(figsize=figsize)
descriptions = [w[2][:40] + '...' if len(w[2]) > 40 else w[2]
for w in wpas_sorted]
values = [w[1] for w in wpas_sorted]
colors = [self.home_color if v > 0 else self.away_color for v in values]
y_pos = range(len(descriptions))
ax.barh(y_pos, values, color=colors)
ax.set_yticks(y_pos)
ax.set_yticklabels(descriptions, fontsize=9)
ax.set_xlabel('Win Probability Added')
ax.set_title(f'Top {top_n} Win Probability Swings',
fontsize=14, fontweight='bold')
ax.axvline(0, color='black', linewidth=0.5)
# Value labels
for i, v in enumerate(values):
x_pos = v + 0.01 if v > 0 else v - 0.01
ha = 'left' if v > 0 else 'right'
ax.text(x_pos, i, f'{v:+.1%}', va='center', ha=ha, fontsize=9)
ax.invert_yaxis()
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
return fig
13.4 Play-Level Performance Visualization
EPA Distribution by Play Type
Understanding how different play types generate value helps identify offensive strengths and weaknesses:
class PlayPerformanceVisualizer:
"""Visualize play-level performance metrics."""
def create_epa_by_play_type(self,
plays: List[Dict],
figsize: tuple = (12, 6)) -> plt.Figure:
"""
Create EPA distribution comparison by play type.
Args:
plays: List of plays with play_type and epa
figsize: Figure dimensions
"""
import seaborn as sns
# Separate by play type
pass_plays = [p['epa'] for p in plays if p.get('play_type') == 'pass']
rush_plays = [p['epa'] for p in plays if p.get('play_type') == 'rush']
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize, sharey=True)
# Pass EPA distribution
if pass_plays:
ax1.hist(pass_plays, bins=25, density=True, alpha=0.7,
color='#4a7ca8', edgecolor='white')
ax1.axvline(np.mean(pass_plays), color='#264653', linestyle='--',
linewidth=2, label=f'Mean: {np.mean(pass_plays):.2f}')
ax1.axvline(0, color='red', linestyle=':', linewidth=1, alpha=0.7)
ax1.set_title(f'Pass Plays (n={len(pass_plays)})',
fontsize=12, fontweight='bold')
ax1.set_xlabel('EPA')
ax1.set_ylabel('Density')
ax1.legend()
# Rush EPA distribution
if rush_plays:
ax2.hist(rush_plays, bins=25, density=True, alpha=0.7,
color='#2a9d8f', edgecolor='white')
ax2.axvline(np.mean(rush_plays), color='#264653', linestyle='--',
linewidth=2, label=f'Mean: {np.mean(rush_plays):.2f}')
ax2.axvline(0, color='red', linestyle=':', linewidth=1, alpha=0.7)
ax2.set_title(f'Rush Plays (n={len(rush_plays)})',
fontsize=12, fontweight='bold')
ax2.set_xlabel('EPA')
ax2.legend()
for ax in [ax1, ax2]:
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
fig.suptitle('EPA Distribution by Play Type',
fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
return fig
def create_situational_success_matrix(self,
plays: List[Dict],
metric: str = 'epa',
figsize: tuple = (10, 6)) -> plt.Figure:
"""
Create heatmap showing performance by down and distance.
Args:
plays: List of plays
metric: 'epa' or 'success_rate'
figsize: Figure dimensions
"""
# Group plays by down and distance
downs = [1, 2, 3]
dist_bins = [(1, 3), (4, 6), (7, 10), (11, 99)]
dist_labels = ['1-3', '4-6', '7-10', '11+']
matrix = np.zeros((len(downs), len(dist_bins)))
counts = np.zeros((len(downs), len(dist_bins)))
for play in plays:
down = play.get('down')
dist = play.get('distance')
if down not in downs or dist is None:
continue
down_idx = downs.index(down)
for j, (low, high) in enumerate(dist_bins):
if low <= dist <= high:
if metric == 'epa':
matrix[down_idx, j] += play.get('epa', 0)
else: # success_rate
matrix[down_idx, j] += 1 if play.get('successful') else 0
counts[down_idx, j] += 1
break
# Calculate averages
with np.errstate(divide='ignore', invalid='ignore'):
matrix = np.where(counts > 0, matrix / counts, np.nan)
fig, ax = plt.subplots(figsize=figsize)
# Determine colormap range
if metric == 'epa':
vmax = max(0.5, np.nanmax(np.abs(matrix)))
im = ax.imshow(matrix, cmap='RdYlGn', aspect='auto',
vmin=-vmax, vmax=vmax)
else:
im = ax.imshow(matrix, cmap='RdYlGn', aspect='auto',
vmin=0.3, vmax=0.6)
# Annotations
for i in range(len(downs)):
for j in range(len(dist_bins)):
if not np.isnan(matrix[i, j]):
if metric == 'epa':
text = f'{matrix[i, j]:+.2f}'
else:
text = f'{matrix[i, j]:.1%}'
color = 'white' if abs(matrix[i, j]) > vmax * 0.4 else 'black'
ax.text(j, i, text, ha='center', va='center',
fontsize=11, fontweight='bold', color=color)
# Sample size
ax.text(j, i + 0.3, f'n={int(counts[i, j])}',
ha='center', va='center', fontsize=8, color='gray')
ax.set_xticks(range(len(dist_labels)))
ax.set_xticklabels(dist_labels)
ax.set_yticks(range(len(downs)))
ax.set_yticklabels([f'{d}{"st" if d==1 else "nd" if d==2 else "rd"} Down'
for d in downs])
ax.set_xlabel('Distance to First Down')
ax.set_ylabel('Down')
metric_label = 'EPA/Play' if metric == 'epa' else 'Success Rate'
ax.set_title(f'{metric_label} by Down and Distance',
fontsize=14, fontweight='bold')
cbar = plt.colorbar(im, ax=ax, shrink=0.8)
cbar.set_label(metric_label)
plt.tight_layout()
return fig
13.5 Sequential Play Visualization
Play Sequence Diagrams
Sequential visualizations show how plays unfold within drives, revealing patterns in play-calling and situational tendencies:
class PlaySequenceVisualizer:
"""Visualize sequences of plays within drives."""
def create_drive_sequence_diagram(self,
plays: List[Dict],
figsize: tuple = (14, 4)) -> plt.Figure:
"""
Create detailed sequence diagram for a drive.
Shows down/distance, play type, yards gained, and EPA
for each play in sequence.
"""
fig, ax = plt.subplots(figsize=figsize)
n_plays = len(plays)
# Draw play boxes
box_width = 0.8
spacing = 1.2
for i, play in enumerate(plays):
x = i * spacing
# Box color based on play type
if play.get('play_type') == 'pass':
box_color = '#4a7ca8'
elif play.get('play_type') == 'rush':
box_color = '#2a9d8f'
else:
box_color = '#6c757d'
# Main box
rect = patches.FancyBboxPatch(
(x - box_width/2, 0.3), box_width, 0.4,
boxstyle="round,pad=0.02",
facecolor=box_color, edgecolor='white', linewidth=2
)
ax.add_patch(rect)
# Down and distance (top)
down = play.get('down', '?')
dist = play.get('distance', '?')
ax.text(x, 0.85, f'{down}&{dist}', ha='center', fontsize=10,
fontweight='bold')
# Play type indicator (in box)
play_type = play.get('play_type', '?')[0].upper()
ax.text(x, 0.5, play_type, ha='center', va='center',
fontsize=14, fontweight='bold', color='white')
# Yards gained (below box)
yards = play.get('yards_gained', 0)
yard_color = '#2a9d8f' if yards > 0 else '#e76f51'
ax.text(x, 0.2, f'{yards:+d}', ha='center', fontsize=10,
color=yard_color, fontweight='bold')
# EPA (bottom)
epa = play.get('epa', 0)
epa_color = '#2a9d8f' if epa > 0 else '#e76f51'
ax.text(x, 0.05, f'EPA: {epa:+.2f}', ha='center', fontsize=8,
color=epa_color)
# Arrow to next play
if i < n_plays - 1:
ax.annotate('', xy=((i+1)*spacing - box_width/2 - 0.1, 0.5),
xytext=(x + box_width/2 + 0.1, 0.5),
arrowprops=dict(arrowstyle='->', color='gray'))
# Drive result
result = plays[-1].get('drive_result', 'unknown')
result_x = (n_plays - 1) * spacing + spacing
result_colors = {
'touchdown': '#2a9d8f',
'field_goal': '#e9c46a',
'turnover': '#e76f51',
'punt': '#8d99ae',
'downs': '#e76f51'
}
ax.text(result_x, 0.5, result.upper(), ha='center', va='center',
fontsize=11, fontweight='bold',
color=result_colors.get(result, 'gray'),
bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
ax.set_xlim(-0.8, result_x + 0.5)
ax.set_ylim(-0.1, 1.0)
ax.axis('off')
# Legend
legend_elements = [
patches.Patch(facecolor='#4a7ca8', label='Pass'),
patches.Patch(facecolor='#2a9d8f', label='Rush'),
]
ax.legend(handles=legend_elements, loc='upper right', fontsize=9)
ax.set_title('Drive Sequence Diagram', fontsize=14, fontweight='bold')
return fig
def create_play_tree(self,
plays: List[Dict],
figsize: tuple = (12, 8)) -> plt.Figure:
"""
Create tree visualization showing drive progression.
Shows branching paths based on play outcomes.
"""
fig, ax = plt.subplots(figsize=figsize)
# Track field position and build tree
positions = [(plays[0]['yard_line'], 0)] # (yard_line, depth)
current_depth = 0
current_yl = plays[0]['yard_line']
for i, play in enumerate(plays):
yards = play.get('yards_gained', 0)
new_yl = min(100, max(0, current_yl + yards))
# Draw branch
ax.plot([current_depth, current_depth + 1],
[current_yl, new_yl],
'o-', linewidth=2, markersize=8,
color='#264653')
# Annotate yards gained
mid_depth = current_depth + 0.5
mid_yl = (current_yl + new_yl) / 2
ax.text(mid_depth, mid_yl + 2, f'{yards:+d}',
ha='center', fontsize=8,
color='#2a9d8f' if yards > 0 else '#e76f51')
current_depth += 1
current_yl = new_yl
# End zone markers
ax.axhline(100, color='#2a9d8f', linestyle='--', alpha=0.5)
ax.axhline(0, color='#e76f51', linestyle='--', alpha=0.5)
ax.set_xlabel('Play Number')
ax.set_ylabel('Yard Line')
ax.set_title('Drive Progression Tree', fontsize=14, fontweight='bold')
ax.set_xlim(-0.5, len(plays) + 0.5)
ax.set_ylim(-5, 105)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
return fig
13.6 Animated Visualizations
Creating Animated Drive Replays
Animation brings play-by-play data to life, showing the temporal flow of drives and games:
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
class AnimatedDriveVisualizer:
"""Create animated drive visualizations."""
def create_animated_drive(self,
plays: List[Dict],
interval: int = 1000) -> FuncAnimation:
"""
Create animated drive chart showing plays one at a time.
Args:
plays: List of play dictionaries
interval: Milliseconds between frames
Returns:
matplotlib FuncAnimation object
"""
fig, ax = plt.subplots(figsize=(14, 4))
# Draw static field
self._draw_field(ax)
# Initialize animated elements
ball_marker, = ax.plot([], [], 'o', markersize=15, color='brown',
markeredgecolor='white', markeredgewidth=2)
trail_line, = ax.plot([], [], '-', color='#264653', linewidth=2)
play_text = ax.text(50, 0.85, '', ha='center', fontsize=12,
fontweight='bold')
epa_text = ax.text(50, 0.15, '', ha='center', fontsize=11)
# Data for animation
yard_lines = [plays[0]['yard_line']]
for play in plays:
yard_lines.append(min(100, max(0,
play['yard_line'] + play.get('yards_gained', 0))))
def init():
ball_marker.set_data([], [])
trail_line.set_data([], [])
play_text.set_text('')
epa_text.set_text('')
return ball_marker, trail_line, play_text, epa_text
def animate(frame):
if frame < len(plays):
play = plays[frame]
# Update ball position
ball_marker.set_data([yard_lines[frame + 1]], [0.5])
# Update trail
trail_line.set_data(yard_lines[:frame + 2],
[0.5] * (frame + 2))
# Update text
down = play.get('down', '?')
dist = play.get('distance', '?')
yards = play.get('yards_gained', 0)
play_text.set_text(f"Play {frame + 1}: {down}&{dist} - {yards:+d} yards")
epa = play.get('epa', 0)
epa_color = '#2a9d8f' if epa > 0 else '#e76f51'
epa_text.set_text(f"EPA: {epa:+.2f}")
epa_text.set_color(epa_color)
return ball_marker, trail_line, play_text, epa_text
ax.set_xlim(-5, 105)
ax.set_ylim(0, 1)
ax.axis('off')
anim = FuncAnimation(fig, animate, init_func=init,
frames=len(plays), interval=interval,
blit=True, repeat=True)
return anim
def _draw_field(self, ax):
"""Draw football field background."""
field = patches.Rectangle((0, 0.3), 100, 0.4,
facecolor='#2e5a1c',
edgecolor='white', linewidth=2)
ax.add_patch(field)
for yl in range(0, 101, 10):
ax.axvline(yl, color='white', linewidth=0.5, alpha=0.3,
ymin=0.3, ymax=0.7)
def save_animation(self, anim: FuncAnimation, filename: str,
fps: int = 1, dpi: int = 150):
"""Save animation to file."""
anim.save(filename, writer='pillow', fps=fps, dpi=dpi)
13.7 Case Study: Visualizing a Championship Game
Let's apply these techniques to visualize key moments from a championship game:
def analyze_championship_game():
"""Complete analysis of a championship game."""
# Sample game data
game_plays = [
# Drive 1 - Opening drive
{'quarter': 1, 'time': '15:00', 'down': 1, 'distance': 10,
'yard_line': 25, 'play_type': 'rush', 'yards_gained': 4,
'epa': 0.1, 'wp_before': 0.50, 'wp_after': 0.51},
{'quarter': 1, 'time': '14:35', 'down': 2, 'distance': 6,
'yard_line': 29, 'play_type': 'pass', 'yards_gained': 15,
'epa': 1.2, 'wp_before': 0.51, 'wp_after': 0.55},
# ... more plays
]
# Create visualizations
drive_viz = DriveChartVisualizer()
wp_viz = WinProbabilityVisualizer()
perf_viz = PlayPerformanceVisualizer()
# 1. Drive summary
fig1 = drive_viz.create_single_drive_chart(
game_plays[:5],
title="Opening Drive - State vs. Rival"
)
# 2. Win probability chart
fig2 = wp_viz.create_game_wp_chart(
game_plays,
home_team="State",
away_team="Rival",
key_moments=[
{'time_index': 10, 'description': 'Pick-6'},
{'time_index': 25, 'description': 'Go-ahead TD'}
]
)
# 3. Performance analysis
fig3 = perf_viz.create_situational_success_matrix(game_plays, metric='epa')
return fig1, fig2, fig3
Summary
Play-by-play visualization transforms granular game data into compelling visual narratives. Key techniques covered in this chapter:
- Drive Charts: Show possession flow and outcome using field-based representations
- EPA Annotation: Add value context to traditional yards-based visualizations
- Win Probability Curves: Capture game momentum and key turning points
- Situational Matrices: Reveal performance patterns by down and distance
- Sequential Diagrams: Show play-by-play progression with full context
- Animation: Bring static data to life with temporal playback
These visualizations serve multiple audiences: coaches reviewing game film, analysts preparing scouting reports, and fans seeking deeper understanding of game flow.
Key Concepts
- EPA (Expected Points Added): The change in expected points from before to after a play
- WPA (Win Probability Added): The change in win probability from a single play
- Drive Chart: Visual representation of offensive possessions
- Situational Analysis: Performance breakdown by game state (down, distance)
- Sequential Visualization: Showing the temporal order of events
Practice Exercises
- Create a drive chart for your favorite team's best drive this season
- Build a win probability visualization for a close game
- Generate a situational heatmap comparing two teams
- Create an animated replay of a key drive
See the exercises.md file for complete practice problems.