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
-
Simplicity Wins: Early versions had more complex visualizations; simpler designs performed better with viewers
-
Latency is Critical: Even 2-3 extra seconds of delay felt noticeable; optimization was essential
-
Caching is Powerful: Pre-computing common visualizations reduced render time by 60%
-
Graceful Degradation: When data was delayed or missing, the system showed the last known state rather than errors
Design Lessons
-
Context Matters: The WP meter alone wasn't enough; viewers needed the change indicator to understand significance
-
Color Consistency: Using team colors consistently across all graphics helped viewers quickly orient
-
Less is More: We removed 3 planned graphics that added clutter without adding insight
Discussion Questions
-
How would you adapt this system for a mobile app experience?
-
What additional graphics would you add for playoff or championship games?
-
How could machine learning improve the real-time processing?
-
What accessibility considerations should be addressed?
-
How would you handle controversial plays where the official result is under review?