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

  1. Fill colors: Using team colors for the fill areas makes it immediately clear which team was favored at each moment
  2. Key moment annotations: Limited to 8 crucial plays to avoid clutter while capturing the narrative
  3. "THE COMEBACK" label: Subtle diagonal arrow emphasizes the second-half transformation
  4. 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

  1. How would this visualization change if State had lost? Would the same key moments be highlighted?

  2. What additional context could be added without cluttering the win probability chart?

  3. How might this visualization be adapted for different audiences (broadcast, coaching staff, fans)?

  4. What role does the "lowest point" play in making comeback visualizations compelling?

  5. How could animation enhance the storytelling aspect of these visualizations?