Case Study 1: Visualizing a Championship Comeback
Overview
Game: State University vs. Rival University - Conference Championship Final Score: State 31, Rival 28 Key Narrative: State overcame a 21-7 halftime deficit with a dominant second half Visualization Challenge: Capture the momentum shift and key moments
The Context
State University entered the conference championship as the #2 seed facing #1 Rival University. The first half was a disaster for State: three turnovers, 45% completion rate, and a 14-point deficit. Sports talk radio was already writing the obituary.
Then something changed.
State's second-half transformation became the story of the season—but the traditional box score only told part of the tale. Our challenge was to create visualizations that captured not just what happened, but the momentum and pivotal moments that defined this remarkable comeback.
The Data
We had access to complete play-by-play data for all 142 plays:
# Sample structure of our game data
game_data = {
'game_id': 'CONF_CHAMP_2024',
'teams': {
'home': 'State',
'away': 'Rival'
},
'final_score': {'State': 31, 'Rival': 28},
'plays': [
# 142 plays with full detail
]
}
# Each play contained:
play_example = {
'play_id': 47,
'quarter': 3,
'time': '11:23',
'possession': 'State',
'down': 2,
'distance': 8,
'yard_line': 35,
'play_type': 'pass',
'yards_gained': 65,
'result': 'touchdown',
'ep_before': 1.2,
'ep_after': 7.0,
'epa': 5.8,
'wp_before': 0.22,
'wp_after': 0.38,
'wpa': 0.16
}
Visualization 1: The Win Probability Narrative
Design Goals
- Show the complete emotional arc of the game
- Identify specific moments where momentum shifted
- Make the comeback feel as dramatic as it was in real-time
Implementation
import matplotlib.pyplot as plt
import numpy as np
def create_championship_wp_chart(plays):
"""Create the signature win probability chart for the championship."""
fig, ax = plt.subplots(figsize=(14, 7))
# Extract WP data
times = []
wps = []
for play in plays:
quarter = play['quarter']
time_str = play['time']
mins, secs = map(int, time_str.split(':'))
game_min = (quarter - 1) * 15 + (15 - mins) - secs/60
times.append(game_min)
wps.append(play['wp_after'])
# Start at 50%
times = [0] + times
wps = [0.50] + wps
# Fill areas with team colors
state_color = '#CC0033'
rival_color = '#003366'
ax.fill_between(times, wps, 0.5,
where=[wp >= 0.5 for wp in wps],
color=state_color, alpha=0.3, interpolate=True)
ax.fill_between(times, wps, 0.5,
where=[wp < 0.5 for wp in wps],
color=rival_color, alpha=0.3, interpolate=True)
# Main WP line
ax.plot(times, wps, color='#264653', linewidth=2.5)
# Key moment annotations
key_moments = [
(12, 0.32, 'State fumble\nat own 25'),
(18, 0.25, 'Rival TD\n21-7'),
(32, 0.22, 'HALFTIME\nLowest point'),
(38, 0.38, 'State 65-yd TD\nMomentum shift'),
(45, 0.52, 'Interception\nState leads WP'),
(52, 0.68, 'Go-ahead TD\n28-21 State'),
(58, 0.55, 'Rival ties it\n28-28'),
(59, 0.78, 'Game-winning\nFG drive')
]
for time, wp, text in key_moments:
ax.scatter([time], [wp], s=100, color='#264653', zorder=5,
edgecolors='white', linewidths=2)
# Alternate annotation positions
y_offset = 0.12 if wp > 0.5 else -0.12
va = 'bottom' if wp > 0.5 else 'top'
ax.annotate(text, (time, wp),
xytext=(0, y_offset * 100), textcoords='offset points',
ha='center', va=va, fontsize=8,
bbox=dict(boxstyle='round,pad=0.3', facecolor='white',
alpha=0.9, edgecolor='gray'),
arrowprops=dict(arrowstyle='->', color='gray', alpha=0.5))
# Halftime marker
ax.axvline(30, color='gray', linestyle='--', linewidth=1, alpha=0.7)
ax.text(30, 0.05, 'HALFTIME', ha='center', fontsize=9, color='gray')
# Quarter markers
for q in [15, 45]:
ax.axvline(q, color='gray', linestyle=':', linewidth=0.5)
# 50% reference line
ax.axhline(0.5, color='gray', linestyle='-', linewidth=1, alpha=0.5)
# Labels
ax.set_xlabel('Game Time (minutes)', fontsize=11)
ax.set_ylabel('State Win Probability', fontsize=11)
ax.set_title('Championship Comeback: State vs. Rival',
fontsize=16, fontweight='bold', pad=15)
# Y-axis formatting
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%'])
# X-axis formatting
ax.set_xlim(0, 60)
ax.set_xticks([7.5, 22.5, 37.5, 52.5])
ax.set_xticklabels(['Q1', 'Q2', 'Q3', 'Q4'])
# Team labels
ax.text(0.02, 0.97, 'STATE FAVORED', ha='left', va='top',
transform=ax.transAxes, fontsize=10, fontweight='bold',
color=state_color)
ax.text(0.02, 0.03, 'RIVAL FAVORED', ha='left', va='bottom',
transform=ax.transAxes, fontsize=10, fontweight='bold',
color=rival_color)
# Comeback indicator
ax.annotate('', xy=(32, 0.22), xytext=(59, 0.78),
arrowprops=dict(arrowstyle='<-', color=state_color,
lw=3, alpha=0.3))
ax.text(45, 0.48, 'THE\nCOMEBACK', ha='center', va='center',
fontsize=12, fontweight='bold', color=state_color, alpha=0.5)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
return fig
Design Decisions
- Fill colors: Using team colors for the fill areas makes it immediately clear which team was favored at each moment
- Key moment annotations: Limited to 8 crucial plays to avoid clutter while capturing the narrative
- "THE COMEBACK" label: Subtle diagonal arrow emphasizes the second-half transformation
- Halftime emphasis: Clear marker at the lowest point of State's win probability
Visualization 2: The Turning Point Drive
The third quarter opening drive changed everything. We created a detailed drive chart to capture this moment:
Implementation
def create_turning_point_drive():
"""Visualize State's momentum-shifting 3rd quarter TD drive."""
# The actual plays from the drive
turning_point_drive = [
{'down': 1, 'distance': 10, 'yard_line': 25, 'play_type': 'rush',
'yards': 4, 'epa': 0.1, 'desc': '4-yd run'},
{'down': 2, 'distance': 6, 'yard_line': 29, 'play_type': 'pass',
'yards': 8, 'epa': 0.6, 'desc': '8-yd completion'},
{'down': 1, 'distance': 10, 'yard_line': 37, 'play_type': 'pass',
'yards': 0, 'epa': -0.4, 'desc': 'Incomplete'},
{'down': 2, 'distance': 10, 'yard_line': 37, 'play_type': 'rush',
'yards': 3, 'epa': -0.1, 'desc': '3-yd run'},
{'down': 3, 'distance': 7, 'yard_line': 40, 'play_type': 'pass',
'yards': 12, 'epa': 1.2, 'desc': '12-yd conversion'},
{'down': 1, 'distance': 10, 'yard_line': 52, 'play_type': 'rush',
'yards': 6, 'epa': 0.3, 'desc': '6-yd run'},
{'down': 2, 'distance': 4, 'yard_line': 58, 'play_type': 'pass',
'yards': 22, 'epa': 1.8, 'desc': '22-yd completion'},
{'down': 1, 'distance': 10, 'yard_line': 80, 'play_type': 'rush',
'yards': 15, 'epa': 1.5, 'desc': '15-yd run'},
{'down': 1, 'distance': 5, 'yard_line': 95, 'play_type': 'rush',
'yards': 5, 'epa': 2.2, 'desc': 'TD run', 'touchdown': True}
]
fig, (ax_field, ax_epa) = plt.subplots(2, 1, figsize=(14, 8),
height_ratios=[1, 1])
# Top: Field visualization
draw_field(ax_field)
current_yl = 25
for i, play in enumerate(turning_point_drive):
# Determine color by EPA
epa = play['epa']
if epa >= 1.0:
color = '#1a9641' # Big positive
elif epa >= 0.3:
color = '#a6d96a' # Positive
elif epa >= -0.3:
color = '#ffffbf' # Neutral
else:
color = '#fdae61' # Negative
yards = play['yards']
next_yl = current_yl + yards
# Draw segment
ax_field.plot([current_yl, next_yl], [0.5, 0.5],
color=color, linewidth=8, solid_capstyle='round')
# Play number marker
ax_field.scatter(current_yl, 0.5, s=80, c='white', zorder=5,
edgecolors=color, linewidths=2)
ax_field.text(current_yl, 0.5, str(i+1), ha='center', va='center',
fontsize=8, fontweight='bold')
current_yl = next_yl
# Touchdown marker
ax_field.scatter(100, 0.5, s=200, c='#2a9d8f', marker='*', zorder=10)
ax_field.set_xlim(0, 105)
ax_field.set_ylim(0, 1)
ax_field.axis('off')
ax_field.set_title('The Turning Point: 9-play, 75-yard TD Drive',
fontsize=14, fontweight='bold')
# Bottom: EPA breakdown
plays = range(1, 10)
epas = [p['epa'] for p in turning_point_drive]
colors = [('#2a9d8f' if e > 0 else '#e76f51') for e in epas]
bars = ax_epa.bar(plays, epas, color=colors, edgecolor='white')
# Cumulative line
cumulative = np.cumsum(epas)
ax_epa.plot(plays, cumulative, 'ko-', markersize=6, linewidth=2,
label='Cumulative EPA')
ax_epa.axhline(0, color='black', linewidth=0.5)
ax_epa.set_xlabel('Play Number')
ax_epa.set_ylabel('EPA')
ax_epa.set_title('EPA by Play', fontsize=12, fontweight='bold')
# Total annotation
total_epa = sum(epas)
ax_epa.text(0.98, 0.95, f'Drive EPA: +{total_epa:.1f}',
ha='right', va='top', transform=ax_epa.transAxes,
fontsize=12, fontweight='bold',
bbox=dict(boxstyle='round', facecolor='#2a9d8f', alpha=0.3))
ax_epa.legend(loc='upper left')
ax_epa.spines['top'].set_visible(False)
ax_epa.spines['right'].set_visible(False)
plt.tight_layout()
return fig
def draw_field(ax):
"""Draw simplified football field."""
from matplotlib.patches import Rectangle
# Field background
field = Rectangle((0, 0.3), 100, 0.4, facecolor='#2e5a1c',
edgecolor='white', linewidth=2)
ax.add_patch(field)
# Yard lines
for yl in range(0, 101, 10):
ax.axvline(yl, color='white', linewidth=0.5, alpha=0.3,
ymin=0.3, ymax=0.7)
# End zones
ax.axvspan(0, 0, facecolor='red', alpha=0.3)
ax.axvspan(100, 100, facecolor='green', alpha=0.3)
# Yard markers
for yl in [10, 20, 30, 40, 50, 40, 30, 20, 10]:
display_yl = yl if yl <= 50 else 100 - yl
x_pos = [10, 20, 30, 40, 50, 60, 70, 80, 90][
[10, 20, 30, 40, 50, 40, 30, 20, 10].index(yl)]
ax.text(x_pos, 0.25, str(display_yl), ha='center',
fontsize=8, color='white', alpha=0.7)
Visualization 3: First Half vs. Second Half Comparison
Implementation
def create_half_comparison():
"""Compare State's first half and second half performance."""
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# Data
metrics = ['EPA/Play', 'Success Rate', 'Explosive %']
first_half = [0.02, 0.35, 0.08]
second_half = [0.28, 0.52, 0.18]
# Chart 1: Side-by-side bars
ax1 = axes[0]
x = np.arange(len(metrics))
width = 0.35
bars1 = ax1.bar(x - width/2, first_half, width, label='1st Half',
color='#e76f51', alpha=0.8)
bars2 = ax1.bar(x + width/2, second_half, width, label='2nd Half',
color='#2a9d8f', alpha=0.8)
ax1.set_ylabel('Value')
ax1.set_xticks(x)
ax1.set_xticklabels(metrics)
ax1.legend()
ax1.set_title('Offensive Efficiency Comparison', fontweight='bold')
ax1.axhline(0, color='black', linewidth=0.5)
# Chart 2: EPA by Quarter
ax2 = axes[1]
quarters = ['Q1', 'Q2', 'Q3', 'Q4']
state_epa = [-0.05, 0.08, 0.35, 0.22]
colors = ['#e76f51', '#e76f51', '#2a9d8f', '#2a9d8f']
ax2.bar(quarters, state_epa, color=colors, edgecolor='white')
ax2.axhline(0, color='black', linewidth=0.5)
ax2.set_ylabel('EPA/Play')
ax2.set_title('EPA by Quarter', fontweight='bold')
# Halftime line
ax2.axvline(1.5, color='gray', linestyle='--', linewidth=2)
ax2.text(1.5, ax2.get_ylim()[1], 'HALFTIME', ha='center', va='bottom',
fontsize=9, color='gray')
# Chart 3: Turnover comparison
ax3 = axes[2]
categories = ['1st Half', '2nd Half']
turnovers = [3, 0]
takeaways = [0, 2]
x = np.arange(len(categories))
ax3.bar(x - 0.2, turnovers, 0.4, label='Turnovers (Bad)',
color='#e76f51')
ax3.bar(x + 0.2, takeaways, 0.4, label='Takeaways (Good)',
color='#2a9d8f')
ax3.set_ylabel('Count')
ax3.set_xticks(x)
ax3.set_xticklabels(categories)
ax3.legend()
ax3.set_title('Turnover Battle', fontweight='bold')
plt.suptitle('STATE: A Tale of Two Halves', fontsize=16,
fontweight='bold', y=1.02)
plt.tight_layout()
return fig
Key Insights from the Visualizations
1. The Win Probability Chart Revealed
- State's lowest point: 22% WP at halftime
- The interception in Q3 was the first moment State was favored (WP > 50%)
- Despite the dramatic finish, State was favored for most of Q4
2. The Turning Point Drive Analysis
- +7.2 total EPA for the drive
- Critical 3rd & 7 conversion (play 5) kept the drive alive
- Three explosive plays (12, 22, and 15 yards) provided the chunk gains
3. The Half Comparison Showed
- EPA/Play improved from 0.02 to 0.28 (1,300% increase)
- Success rate jumped from 35% to 52%
- Turnover differential: -3 first half, +2 second half
Design Lessons Learned
1. Let the Data Tell the Story
The win probability chart naturally creates narrative tension. We enhanced rather than invented the drama.
2. Annotation Restraint
We limited key moment annotations to 8 plays out of 142. More would have cluttered the visualization and diluted the impact.
3. Color as Emotional Cue
Team colors in the WP fill areas create immediate emotional association. Red (Rival) dominating early, green (State) taking over—the colors reinforce the narrative.
4. Multiple Perspectives
Three different visualizations (game flow, single drive, statistical comparison) told complementary parts of the same story.
Discussion Questions
-
How would this visualization change if State had lost? Would the same key moments be highlighted?
-
What additional context could be added without cluttering the win probability chart?
-
How might this visualization be adapted for different audiences (broadcast, coaching staff, fans)?
-
What role does the "lowest point" play in making comeback visualizations compelling?
-
How could animation enhance the storytelling aspect of these visualizations?