4 min read

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...

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:

  1. Create drive charts that show the flow of a single possession
  2. Build win probability curves that capture game momentum
  3. Visualize play-by-play EPA to identify key moments
  4. Design field visualizations showing play locations and outcomes
  5. Construct animated sequences that bring static data to life
  6. 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:

  1. Drive Charts: Show possession flow and outcome using field-based representations
  2. EPA Annotation: Add value context to traditional yards-based visualizations
  3. Win Probability Curves: Capture game momentum and key turning points
  4. Situational Matrices: Reveal performance patterns by down and distance
  5. Sequential Diagrams: Show play-by-play progression with full context
  6. 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

  1. Create a drive chart for your favorite team's best drive this season
  2. Build a win probability visualization for a close game
  3. Generate a situational heatmap comparing two teams
  4. Create an animated replay of a key drive

See the exercises.md file for complete practice problems.