Case Study 2: Building a Real-Time Game Visualization System

Overview

Project: Real-time play-by-play visualization dashboard for game broadcasts Client: Regional Sports Network Challenge: Update visualizations within 10 seconds of each play Outcome: Deployed system used for 150+ games in 2024 season


Project Background

The Regional Sports Network approached our analytics team with a challenge: their broadcasts showed traditional stats (yards, score, time), but they wanted to incorporate advanced analytics in a way that enhanced rather than confused the viewing experience.

Key requirements: 1. Win probability meter updating after every play 2. Drive progress visualization 3. Key play highlighting (big WPA swings) 4. Simple enough for casual fans 5. Update latency under 10 seconds


System Architecture

Data Pipeline

Play-by-Play Feed → Processing Server → Visualization API → Broadcast Graphics
        │                  │                    │
    JSON stream      EPA/WPA calc          matplotlib
    ~5 sec delay      ~2 sec              rendering
                                           ~3 sec

Core Data Processing

from dataclasses import dataclass
from typing import Optional
import numpy as np

@dataclass
class GameState:
    """Current game state for visualization."""
    quarter: int
    time_remaining: str
    down: int
    distance: int
    yard_line: int
    home_score: int
    away_score: int
    possession: str
    home_wp: float

@dataclass
class PlayResult:
    """Result of a single play."""
    yards_gained: int
    play_type: str
    result: str  # 'completion', 'incompletion', 'rush', etc.
    turnover: bool
    scoring: bool
    epa: float
    wpa: float
    description: str

class RealTimeProcessor:
    """Process incoming plays and update game state."""

    def __init__(self, home_team: str, away_team: str):
        self.home_team = home_team
        self.away_team = away_team
        self.plays = []
        self.current_drive = []
        self.all_drives = []

    def process_play(self, raw_play: dict) -> tuple:
        """
        Process incoming play data.

        Returns:
            (GameState, PlayResult) tuple for visualization
        """
        # Parse game state
        game_state = GameState(
            quarter=raw_play['quarter'],
            time_remaining=raw_play['time'],
            down=raw_play['down'],
            distance=raw_play['distance'],
            yard_line=raw_play['yard_line'],
            home_score=raw_play['home_score'],
            away_score=raw_play['away_score'],
            possession=raw_play['possession'],
            home_wp=raw_play['home_wp']
        )

        # Calculate EPA and WPA
        play_result = PlayResult(
            yards_gained=raw_play['yards_gained'],
            play_type=raw_play['play_type'],
            result=raw_play['result'],
            turnover=raw_play.get('turnover', False),
            scoring=raw_play.get('scoring', False),
            epa=self._calculate_epa(raw_play),
            wpa=self._calculate_wpa(raw_play),
            description=raw_play.get('description', '')
        )

        # Update drive tracking
        self._update_drive(game_state, play_result)

        # Store play
        self.plays.append((game_state, play_result))

        return game_state, play_result

    def _calculate_epa(self, play: dict) -> float:
        """Calculate EPA from play data."""
        return play.get('ep_after', 0) - play.get('ep_before', 0)

    def _calculate_wpa(self, play: dict) -> float:
        """Calculate WPA from play data."""
        return play.get('wp_after', 0.5) - play.get('wp_before', 0.5)

    def _update_drive(self, state: GameState, result: PlayResult):
        """Update drive tracking."""
        if result.turnover or result.scoring or self._is_change_of_possession():
            if self.current_drive:
                self.all_drives.append(self.current_drive.copy())
            self.current_drive = []
        else:
            self.current_drive.append((state, result))

    def _is_change_of_possession(self) -> bool:
        """Check if possession changed."""
        if len(self.plays) < 2:
            return False
        return self.plays[-1][0].possession != self.plays[-2][0].possession

    def get_wp_history(self) -> list:
        """Get win probability history for charting."""
        return [(i, p[0].home_wp) for i, p in enumerate(self.plays)]

    def get_current_drive_summary(self) -> dict:
        """Get current drive statistics."""
        if not self.current_drive:
            return {'plays': 0, 'yards': 0, 'epa': 0}

        return {
            'plays': len(self.current_drive),
            'yards': sum(p[1].yards_gained for p in self.current_drive),
            'epa': sum(p[1].epa for p in self.current_drive)
        }

Visualization Components

Component 1: Win Probability Meter

The centerpiece of our broadcast graphics—a simple gauge showing current win probability.

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np

class WinProbabilityMeter:
    """Broadcast-ready win probability display."""

    def __init__(self, home_team: str, away_team: str,
                 home_color: str, away_color: str):
        self.home_team = home_team
        self.away_team = away_team
        self.home_color = home_color
        self.away_color = away_color

    def render(self, home_wp: float, figsize=(8, 3)) -> plt.Figure:
        """
        Render win probability meter.

        Args:
            home_wp: Home team win probability (0-1)
            figsize: Figure dimensions
        """
        fig, ax = plt.subplots(figsize=figsize)
        fig.patch.set_facecolor('#1a1a2e')
        ax.set_facecolor('#1a1a2e')

        # Background bar
        ax.barh(0, 1.0, height=0.5, color='#333333', edgecolor='none')

        # Home team portion (from left)
        ax.barh(0, home_wp, height=0.5, color=self.home_color, edgecolor='none')

        # Percentage text
        home_pct = int(home_wp * 100)
        away_pct = 100 - home_pct

        ax.text(home_wp / 2, 0, f'{home_pct}%',
               ha='center', va='center', fontsize=24,
               color='white', fontweight='bold')

        ax.text((1 + home_wp) / 2, 0, f'{away_pct}%',
               ha='center', va='center', fontsize=24,
               color='white', fontweight='bold')

        # Team labels
        ax.text(0.02, 0.6, self.home_team, ha='left', va='bottom',
               fontsize=12, color=self.home_color, fontweight='bold',
               transform=ax.transAxes)
        ax.text(0.98, 0.6, self.away_team, ha='right', va='bottom',
               fontsize=12, color=self.away_color, fontweight='bold',
               transform=ax.transAxes)

        # Center marker
        ax.axvline(0.5, color='white', linewidth=2, linestyle='-')

        ax.set_xlim(0, 1)
        ax.set_ylim(-0.5, 0.8)
        ax.axis('off')

        plt.tight_layout()
        return fig

    def render_with_change(self, home_wp: float, change: float,
                          figsize=(8, 4)) -> plt.Figure:
        """Render meter with WPA change indicator."""
        fig = self.render(home_wp, figsize)
        ax = fig.axes[0]

        # Change indicator
        if abs(change) > 0.03:  # Only show significant changes
            change_text = f'+{int(change*100)}%' if change > 0 else f'{int(change*100)}%'
            change_color = '#2a9d8f' if change > 0 else '#e76f51'

            ax.text(0.5, -0.35, f'WPA: {change_text}',
                   ha='center', va='center', fontsize=14,
                   color=change_color, fontweight='bold',
                   transform=ax.transAxes)

        return fig

Component 2: Mini Win Probability Chart

A compact chart showing WP history throughout the game.

class MiniWPChart:
    """Compact win probability chart for broadcast."""

    def __init__(self, home_team: str, away_team: str,
                 home_color: str, away_color: str):
        self.home_team = home_team
        self.away_team = away_team
        self.home_color = home_color
        self.away_color = away_color

    def render(self, wp_history: list, figsize=(10, 3)) -> plt.Figure:
        """
        Render compact WP chart.

        Args:
            wp_history: List of (play_index, home_wp) tuples
        """
        fig, ax = plt.subplots(figsize=figsize)
        fig.patch.set_facecolor('#1a1a2e')
        ax.set_facecolor('#1a1a2e')

        if not wp_history:
            return fig

        plays = [p[0] for p in wp_history]
        wps = [p[1] for p in wp_history]

        # Add initial 50%
        plays = [0] + plays
        wps = [0.5] + wps

        # Fill areas
        ax.fill_between(plays, wps, 0.5,
                       where=[wp >= 0.5 for wp in wps],
                       color=self.home_color, alpha=0.4, interpolate=True)
        ax.fill_between(plays, wps, 0.5,
                       where=[wp < 0.5 for wp in wps],
                       color=self.away_color, alpha=0.4, interpolate=True)

        # WP line
        ax.plot(plays, wps, color='white', linewidth=2)

        # Current position marker
        ax.scatter([plays[-1]], [wps[-1]], s=100, color='white',
                  zorder=5, edgecolors=self.home_color if wps[-1] > 0.5
                  else self.away_color, linewidths=3)

        # 50% line
        ax.axhline(0.5, color='white', linewidth=0.5, alpha=0.3)

        # Formatting
        ax.set_ylim(0, 1)
        ax.set_xlim(0, max(plays) * 1.05)

        # Minimal axis styling
        ax.set_yticks([0, 0.5, 1.0])
        ax.set_yticklabels(['0%', '50%', '100%'], color='white', fontsize=9)
        ax.tick_params(colors='white')

        for spine in ax.spines.values():
            spine.set_color('white')
            spine.set_alpha(0.3)

        ax.set_xlabel('Plays', color='white', fontsize=9)

        plt.tight_layout()
        return fig

Component 3: Drive Progress Display

Shows the current drive's progress on a miniature field.

class DriveProgressDisplay:
    """Compact drive progress visualization."""

    def render(self, start_yl: int, current_yl: int,
               plays: int, yards: int,
               figsize=(8, 2)) -> plt.Figure:
        """
        Render drive progress.

        Args:
            start_yl: Starting yard line (1-99)
            current_yl: Current yard line
            plays: Number of plays in drive
            yards: Total yards gained
        """
        fig, ax = plt.subplots(figsize=figsize)
        fig.patch.set_facecolor('#1a1a2e')
        ax.set_facecolor('#1a1a2e')

        # Simplified field
        ax.axhspan(0.3, 0.7, facecolor='#2e5a1c', alpha=0.8)

        # End zones
        ax.axvspan(0, 5, facecolor='#e76f51', alpha=0.5)
        ax.axvspan(95, 100, facecolor='#2a9d8f', alpha=0.5)

        # Yard lines
        for yl in range(10, 100, 10):
            ax.axvline(yl, color='white', linewidth=0.5, alpha=0.3,
                      ymin=0.3, ymax=0.7)

        # Drive progress
        ax.plot([start_yl, current_yl], [0.5, 0.5],
               color='#4a7ca8', linewidth=6, solid_capstyle='round')

        # Start marker
        ax.scatter([start_yl], [0.5], s=100, c='white', zorder=5)

        # Current position (ball)
        ax.scatter([current_yl], [0.5], s=150, c='#f4a261',
                  marker='o', zorder=6, edgecolors='white', linewidths=2)

        # Drive stats
        ax.text(50, 0.15, f'{plays} plays | {yards} yards',
               ha='center', va='center', fontsize=11,
               color='white', fontweight='bold')

        ax.set_xlim(0, 100)
        ax.set_ylim(0, 1)
        ax.axis('off')

        plt.tight_layout()
        return fig

Component 4: Key Play Alert

Highlights significant plays with large WPA.

class KeyPlayAlert:
    """Display for significant plays."""

    def render(self, description: str, wpa: float,
               is_positive: bool, figsize=(8, 2)) -> plt.Figure:
        """Render key play alert."""
        fig, ax = plt.subplots(figsize=figsize)

        # Background color based on impact
        bg_color = '#2a9d8f' if is_positive else '#e76f51'
        fig.patch.set_facecolor(bg_color)
        ax.set_facecolor(bg_color)

        # Alert icon
        icon = '⬆' if is_positive else '⬇'
        ax.text(0.05, 0.5, icon, ha='left', va='center',
               fontsize=36, color='white', transform=ax.transAxes)

        # Description
        ax.text(0.15, 0.65, 'KEY PLAY', ha='left', va='center',
               fontsize=12, color='white', fontweight='bold',
               transform=ax.transAxes)
        ax.text(0.15, 0.35, description[:50], ha='left', va='center',
               fontsize=14, color='white', transform=ax.transAxes)

        # WPA
        wpa_text = f'+{int(wpa*100)}%' if wpa > 0 else f'{int(wpa*100)}%'
        ax.text(0.95, 0.5, wpa_text, ha='right', va='center',
               fontsize=28, color='white', fontweight='bold',
               transform=ax.transAxes)

        ax.axis('off')
        plt.tight_layout()
        return fig

Integration and Performance

Caching Strategy

To meet the 10-second latency requirement, we implemented aggressive caching:

class VisualizationCache:
    """Cache rendered visualizations for performance."""

    def __init__(self):
        self._cache = {}
        self._wp_meter_base = None

    def get_wp_meter(self, home_wp: float, change: float) -> bytes:
        """Get cached or render WP meter."""
        # Round to nearest 1% for caching
        key = (round(home_wp, 2), round(change, 2))

        if key not in self._cache:
            meter = WinProbabilityMeter('HOME', 'AWAY', '#CC0033', '#003366')
            fig = meter.render_with_change(home_wp, change)

            # Convert to bytes
            import io
            buf = io.BytesIO()
            fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
            buf.seek(0)
            self._cache[key] = buf.getvalue()
            plt.close(fig)

        return self._cache[key]

    def clear_old_cache(self, max_entries: int = 100):
        """Remove old cache entries."""
        if len(self._cache) > max_entries:
            # Keep most recent entries
            keys = list(self._cache.keys())
            for key in keys[:-max_entries]:
                del self._cache[key]

Performance Metrics

After deployment, we achieved:

Metric Target Achieved
End-to-end latency <10 sec 7.2 sec avg
WP meter render <2 sec 0.8 sec
Mini chart render <3 sec 1.4 sec
Cache hit rate >50% 72%
Uptime >99% 99.7%

Broadcast Integration

Graphics Overlay System

The rendered PNG images were fed into the broadcast graphics system:

class BroadcastOutput:
    """Output visualizations for broadcast integration."""

    def __init__(self, output_dir: str):
        self.output_dir = output_dir
        self.cache = VisualizationCache()

    def update_graphics(self, game_state: GameState,
                       play_result: PlayResult,
                       processor: RealTimeProcessor):
        """Update all broadcast graphics after a play."""

        # 1. Win probability meter
        wp_image = self.cache.get_wp_meter(
            game_state.home_wp, play_result.wpa)
        self._write_file('wp_meter.png', wp_image)

        # 2. Mini WP chart
        wp_chart = MiniWPChart('HOME', 'AWAY', '#CC0033', '#003366')
        fig = wp_chart.render(processor.get_wp_history())
        self._save_figure(fig, 'wp_chart.png')

        # 3. Drive progress
        drive = processor.get_current_drive_summary()
        progress = DriveProgressDisplay()
        fig = progress.render(
            start_yl=processor.current_drive[0][0].yard_line if processor.current_drive else game_state.yard_line,
            current_yl=game_state.yard_line,
            plays=drive['plays'],
            yards=drive['yards']
        )
        self._save_figure(fig, 'drive_progress.png')

        # 4. Key play alert (if significant)
        if abs(play_result.wpa) > 0.05:
            alert = KeyPlayAlert()
            fig = alert.render(
                play_result.description,
                play_result.wpa,
                play_result.wpa > 0
            )
            self._save_figure(fig, 'key_play.png')

    def _save_figure(self, fig: plt.Figure, filename: str):
        """Save figure to output directory."""
        fig.savefig(f'{self.output_dir}/{filename}',
                   dpi=100, bbox_inches='tight',
                   facecolor=fig.get_facecolor())
        plt.close(fig)

    def _write_file(self, filename: str, data: bytes):
        """Write raw bytes to file."""
        with open(f'{self.output_dir}/{filename}', 'wb') as f:
            f.write(data)

Results and Lessons Learned

Viewer Feedback

Post-season surveys showed: - 78% of viewers found the WP meter "helpful" or "very helpful" - 65% said it enhanced their understanding of game flow - 23% initially found it confusing but learned to appreciate it

Technical Lessons

  1. Simplicity Wins: Early versions had more complex visualizations; simpler designs performed better with viewers

  2. Latency is Critical: Even 2-3 extra seconds of delay felt noticeable; optimization was essential

  3. Caching is Powerful: Pre-computing common visualizations reduced render time by 60%

  4. Graceful Degradation: When data was delayed or missing, the system showed the last known state rather than errors

Design Lessons

  1. Context Matters: The WP meter alone wasn't enough; viewers needed the change indicator to understand significance

  2. Color Consistency: Using team colors consistently across all graphics helped viewers quickly orient

  3. Less is More: We removed 3 planned graphics that added clutter without adding insight


Discussion Questions

  1. How would you adapt this system for a mobile app experience?

  2. What additional graphics would you add for playoff or championship games?

  3. How could machine learning improve the real-time processing?

  4. What accessibility considerations should be addressed?

  5. How would you handle controversial plays where the official result is under review?