Case Study 2: Building a Live Match Dashboard for Coaching Staff
Overview
This case study follows the design, development, and deployment of a live match dashboard at a mid-table club in a top European league. Unlike Case Study 1, which focused on the match-day experience at an elite club with extensive resources, this case study addresses the practical realities faced by clubs with smaller analytics departments and tighter budgets. The project was led by a single data analyst, Sofia, who had six weeks and a modest budget to deliver a working prototype.
The case study covers the full product lifecycle: requirements gathering with coaching staff, architectural decisions, dashboard design, development, testing, and deployment at a real match.
Phase 1: Requirements Gathering (Week 1)
Stakeholder Interviews
Sofia began by interviewing the three primary users: the head coach, the assistant coach (responsible for defensive organization), and the fitness coach.
Head Coach (Marco):
"I need to know who is winning the tactical battle and when things are changing. I don't want numbers---I want pictures. Show me the pitch and tell me what's different from what we prepared."
Assistant Coach (Elena):
"I need the defensive distances: how compact are we? What's the gap between our lines? Are we being beaten in the channels? And I need to know quickly when the opponent changes shape."
Fitness Coach (Dr. Reyes):
"I need player load data---distance, high-speed running, sprints---updated at halftime at minimum, ideally rolling during the match. I need to flag anyone who's approaching their load limits."
Requirements Synthesis
Sofia distilled these interviews into a prioritized feature list:
Must Have (MVP): 1. Live pitch map with player positions and formation overlay 2. Team compactness metrics (defensive line height, width, inter-line distance) 3. Opponent formation detection with change alerts 4. Per-player physical load summary (distance, HSR, sprints) 5. Halftime automated summary
Should Have (V1.1): 6. Momentum score with trend line 7. Pressing intensity visualization 8. Substitution fatigue alerts 9. Set-piece pattern matching
Could Have (V2.0): 10. xG live tracking 11. Video timestamp integration 12. Passing network visualization
Design Constraints
- Budget: Software only (no new hardware). The club already had a tracking data provider streaming data via API.
- Hardware: Sofia would use the club's existing iPad Pro for bench-side display and her own laptop as the processing node.
- Connectivity: Stadium Wi-Fi was unreliable. Sofia secured approval to use a mobile hotspot as the primary connection.
- Regulatory: The domestic league permitted one tablet device in the technical area.
- Timeline: Six weeks to a working prototype for a home league match.
Phase 2: Architecture Design (Week 2)
System Architecture
Given the constraints, Sofia designed a lightweight architecture:
[Tracking API] --> [Python Backend] --> [Web Dashboard]
|
[SQLite Cache]
- Tracking API: The club's provider offered a WebSocket endpoint streaming JSON tracking data at 10 Hz (lower than the 25 Hz raw feed but sufficient for tactical metrics).
- Python Backend: A FastAPI application running on Sofia's laptop that consumed the tracking stream, computed metrics, and served them via a WebSocket to the dashboard.
- Web Dashboard: A responsive web application built with HTML, CSS, and JavaScript (using the Plotly.js library for visualizations) that ran in the iPad's browser.
- SQLite Cache: A local SQLite database for persisting computed metrics and enabling halftime report generation.
Latency Budget
Sofia estimated her latency budget:
| Stage | Estimated Latency |
|---|---|
| Tracking API (provider processing + network) | 500 ms |
| Python metric computation | 200 ms |
| WebSocket to iPad | 50 ms |
| Browser rendering | 100 ms |
| Total | 850 ms |
This was well within the 3-second target for aggregate metrics.
Data Flow Design
from dataclasses import dataclass, field
from typing import Dict, List
@dataclass
class MatchState:
"""Central state object for the live match dashboard.
Attributes:
match_time_seconds: Current match time in seconds.
home_positions: Dictionary mapping player IDs to (x, y) tuples.
away_positions: Dictionary mapping player IDs to (x, y) tuples.
ball_position: Tuple of (x, y) for ball position.
home_formation: Detected formation string (e.g., "4-3-3").
away_formation: Detected formation string.
player_loads: Dictionary mapping player IDs to physical load data.
alerts: List of active alert messages.
"""
match_time_seconds: float = 0.0
home_positions: Dict[str, tuple] = field(default_factory=dict)
away_positions: Dict[str, tuple] = field(default_factory=dict)
ball_position: tuple = (52.5, 34.0)
home_formation: str = "Unknown"
away_formation: str = "Unknown"
player_loads: Dict[str, dict] = field(default_factory=dict)
alerts: List[str] = field(default_factory=list)
Phase 3: Dashboard Design (Week 2--3)
Layout Design
Sofia followed the four-level dashboard hierarchy from the textbook, but given the single-device constraint (one iPad), she designed a tabbed interface:
Tab 1: Match Overview (Level 0 + Level 1) - Top bar: Score, match time, momentum indicator (colored bar) - Main panel: Pitch map with player dots, formation lines, and Voronoi space control - Bottom bar: Three alert slots (latest alerts in color-coded boxes)
Tab 2: Physical Load (Level 2) - Table with one row per player: distance, HSR, sprints, fatigue index - Sparkline trends for each metric (last 15 minutes) - Color-coded cells (green/amber/red based on thresholds)
Tab 3: Tactical Detail (Level 2) - Defensive compactness chart (line height over time) - Inter-line gap visualization - Pressing intensity rolling chart
Tab 4: Halftime Report (Level 3) - Auto-generated summary text - Key statistics comparison table - Top 3 observations
Wireframe Review
Sofia presented paper wireframes to the coaching staff in a 20-minute session. Key feedback:
- Marco (Head Coach): "The pitch map is great. Make the formation lines thicker---I can't see thin lines from a distance. And make the player dots bigger for the opponent."
- Elena (Assistant Coach): "Can I tap on a player to see their individual stats? And I want the defensive gap number displayed on the pitch itself, not in a separate panel."
- Dr. Reyes (Fitness Coach): "The physical load table is perfect. Add a column for 'percentage of match limit' so I know how close they are to their personal caps."
Sofia incorporated all feedback into the design.
Color System
Following accessibility principles, Sofia defined a color system that worked for color-blind users:
| State | Color | Shape/Pattern | Hex Code |
|---|---|---|---|
| Normal | Teal | Solid circle | #1ABC9C |
| Caution | Orange | Triangle marker | #F39C12 |
| Critical | Magenta | Flashing square | #E91E63 |
| Informational | Light blue | Dashed border | #3498DB |
Phase 4: Development (Weeks 3--5)
Backend Development
Sofia built the backend in Python, structuring it as a series of metric computation modules:
import numpy as np
from typing import Dict, List, Tuple
def compute_team_compactness(
positions: Dict[str, Tuple[float, float]],
goalkeeper_id: str,
) -> Dict[str, float]:
"""Compute team compactness metrics from player positions.
Args:
positions: Dictionary mapping player IDs to (x, y) positions.
goalkeeper_id: The player ID of the goalkeeper (excluded from
outfield compactness calculations).
Returns:
Dictionary containing:
- defensive_line_height: Average y of deepest 4 outfield players.
- team_width: Maximum lateral spread.
- team_length: Distance from deepest to most advanced player.
- inter_line_gap: Distance between defensive and midfield lines.
"""
outfield = {
pid: pos for pid, pos in positions.items()
if pid != goalkeeper_id
}
coords = np.array(list(outfield.values()))
# Sort by x-coordinate (assumed: goal-to-goal axis)
x_sorted = np.sort(coords[:, 0])
y_coords = coords[:, 1]
defensive_line = np.mean(x_sorted[:4])
midfield_line = np.mean(x_sorted[4:7])
return {
"defensive_line_height": float(defensive_line),
"team_width": float(np.max(y_coords) - np.min(y_coords)),
"team_length": float(x_sorted[-1] - x_sorted[0]),
"inter_line_gap": float(midfield_line - defensive_line),
}
def compute_player_load(
player_id: str,
position_history: List[Tuple[float, float, float]],
frame_rate: float = 12.0,
) -> Dict[str, float]:
"""Compute physical load metrics for a single player.
Args:
player_id: The player's unique identifier.
position_history: List of (x, y, timestamp) tuples.
frame_rate: Tracking data frame rate in Hz.
Returns:
Dictionary containing:
- total_distance_m: Total distance covered in meters.
- hsr_distance_m: High-speed running distance (>7.5 m/s).
- sprint_distance_m: Sprint distance (>9.0 m/s).
- num_sprints: Number of sprint bouts.
- max_speed_ms: Maximum speed reached.
"""
if len(position_history) < 2:
return {
"total_distance_m": 0.0,
"hsr_distance_m": 0.0,
"sprint_distance_m": 0.0,
"num_sprints": 0,
"max_speed_ms": 0.0,
}
positions = np.array(position_history)
dx = np.diff(positions[:, 0])
dy = np.diff(positions[:, 1])
distances = np.sqrt(dx**2 + dy**2)
speeds = distances * frame_rate # distance per frame * frames per second
total_distance = float(np.sum(distances))
hsr_mask = speeds > 7.5
sprint_mask = speeds > 9.0
# Count sprint bouts (consecutive frames above threshold)
sprint_starts = np.diff(sprint_mask.astype(int))
num_sprints = int(np.sum(sprint_starts == 1))
return {
"total_distance_m": total_distance,
"hsr_distance_m": float(np.sum(distances[hsr_mask])),
"sprint_distance_m": float(np.sum(distances[sprint_mask])),
"num_sprints": num_sprints,
"max_speed_ms": float(np.max(speeds)) if len(speeds) > 0 else 0.0,
}
Formation Detection Module
Sofia implemented a simplified formation detector based on template matching:
import numpy as np
from scipy.optimize import linear_sum_assignment
from typing import Dict, Tuple, List
# Reference formations (normalized to 0-1 pitch coordinates)
FORMATION_TEMPLATES: Dict[str, np.ndarray] = {
"4-4-2": np.array([
[0.15, 0.2], [0.15, 0.4], [0.15, 0.6], [0.15, 0.8],
[0.40, 0.2], [0.40, 0.4], [0.40, 0.6], [0.40, 0.8],
[0.65, 0.35], [0.65, 0.65],
]),
"4-3-3": np.array([
[0.15, 0.15], [0.15, 0.38], [0.15, 0.62], [0.15, 0.85],
[0.40, 0.25], [0.40, 0.50], [0.40, 0.75],
[0.65, 0.15], [0.65, 0.50], [0.65, 0.85],
]),
"4-2-3-1": np.array([
[0.15, 0.15], [0.15, 0.38], [0.15, 0.62], [0.15, 0.85],
[0.35, 0.35], [0.35, 0.65],
[0.55, 0.15], [0.55, 0.50], [0.55, 0.85],
[0.70, 0.50],
]),
"3-5-2": np.array([
[0.15, 0.25], [0.15, 0.50], [0.15, 0.75],
[0.35, 0.10], [0.35, 0.30], [0.35, 0.50], [0.35, 0.70], [0.35, 0.90],
[0.60, 0.35], [0.60, 0.65],
]),
}
def detect_formation(
outfield_positions: np.ndarray,
pitch_length: float = 105.0,
pitch_width: float = 68.0,
) -> Tuple[str, float]:
"""Detect the most likely formation from outfield player positions.
Uses the Hungarian algorithm to find the optimal assignment of players
to template roles for each candidate formation, then selects the
formation with the lowest total assignment cost.
Args:
outfield_positions: Array of shape (10, 2) with outfield player
positions in meters (excluding goalkeeper).
pitch_length: Pitch length in meters.
pitch_width: Pitch width in meters.
Returns:
Tuple of (formation_name, assignment_cost).
"""
# Normalize positions to 0-1 range
normalized = outfield_positions.copy()
normalized[:, 0] /= pitch_length
normalized[:, 1] /= pitch_width
best_formation = "Unknown"
best_cost = float("inf")
for name, template in FORMATION_TEMPLATES.items():
# Compute cost matrix: pairwise squared distances
cost_matrix = np.zeros((10, 10))
for i in range(10):
for j in range(10):
cost_matrix[i, j] = np.sum(
(normalized[i] - template[j]) ** 2
)
# Solve assignment problem
row_ind, col_ind = linear_sum_assignment(cost_matrix)
total_cost = cost_matrix[row_ind, col_ind].sum()
if total_cost < best_cost:
best_cost = total_cost
best_formation = name
return best_formation, best_cost
Halftime Report Generator
from typing import Dict, Any
def generate_halftime_report(
match_state: Dict[str, Any],
home_stats: Dict[str, float],
away_stats: Dict[str, float],
player_loads: Dict[str, Dict[str, float]],
alerts_history: list,
) -> str:
"""Generate an automated halftime report.
Args:
match_state: Current match state dictionary.
home_stats: Aggregated home team statistics.
away_stats: Aggregated away team statistics.
player_loads: Per-player physical load data.
alerts_history: List of all alerts generated during the half.
Returns:
Formatted halftime report as a string.
"""
report_lines = [
"=" * 60,
"HALFTIME REPORT",
"=" * 60,
"",
f"Score: Home {match_state.get('home_goals', 0)} - "
f"{match_state.get('away_goals', 0)} Away",
"",
"--- TEAM COMPARISON ---",
f"{'Metric':<25} {'Home':>10} {'Away':>10}",
"-" * 45,
]
metrics = [
("Possession %", "possession_pct"),
("Total Distance (km)", "total_distance_km"),
("Passes Completed", "passes_completed"),
("Pass Accuracy %", "pass_accuracy_pct"),
("Shots", "shots"),
("Shots on Target", "shots_on_target"),
]
for label, key in metrics:
home_val = home_stats.get(key, 0)
away_val = away_stats.get(key, 0)
report_lines.append(f"{label:<25} {home_val:>12.1f} {away_val:>12.1f}")
report_lines.extend([
"",
"--- PHYSICAL LOAD FLAGS ---",
])
for pid, load in player_loads.items():
pct_of_limit = load.get("pct_of_match_limit", 0)
if pct_of_limit > 45: # More than 45% of match limit used in 1st half
status = "WARNING" if pct_of_limit > 50 else "MONITOR"
report_lines.append(
f" [{status}] {pid}: {pct_of_limit:.0f}% of match limit used"
)
if alerts_history:
report_lines.extend([
"",
"--- KEY ALERTS (1st Half) ---",
])
for alert in alerts_history[-5:]: # Last 5 alerts
report_lines.append(f" - {alert}")
report_lines.extend(["", "=" * 60])
return "\n".join(report_lines)
Testing
Sofia tested the system in three ways:
- Replay testing: Used recorded tracking data from two previous home matches to verify metric calculations against known values.
- Live simulation: Had the youth team play a practice match while testing the full pipeline end-to-end.
- Usability testing: Had Marco, Elena, and Dr. Reyes use the dashboard during the youth team match and collected feedback.
Key findings from testing: - The formation detector had difficulty distinguishing 4-2-3-1 from 4-3-3 when the opposition number 10 dropped deep. Sofia added a confidence score threshold: if the best and second-best formation costs differed by less than 15%, both were displayed. - The iPad's browser occasionally dropped the WebSocket connection. Sofia added automatic reconnection with exponential backoff. - Dr. Reyes requested that the physical load table auto-sort by fatigue index (highest first) rather than by jersey number.
Phase 5: Match-Day Deployment (Week 6)
Pre-Match
Sofia arrived at the stadium two hours before kickoff. Setup procedure:
- Connected her laptop to the stadium's wired Ethernet (primary) and her mobile hotspot (backup).
- Verified the tracking data WebSocket was streaming test data.
- Loaded the opponent's formation template (4-2-3-1 expected based on scouting).
- Calibrated individual fatigue thresholds for each player based on weekly training load data.
- Ran a 5-minute pipeline test with live data.
- Delivered the iPad to the bench and verified it could display the dashboard.
Match Observations
Minute 12: The formation detector correctly identified the opponent switching from 4-2-3-1 to 4-4-2 during a sustained period of pressure. Elena used this information to adjust the pressing trigger.
Minute 28: The compactness monitor showed the inter-line gap expanding to 19 meters during a passage of play. Sofia's alert appeared on the dashboard: "CAUTION: Inter-line gap exceeding 16m threshold for 90+ seconds." Elena saw the alert during the next stoppage and signaled instructions to the midfield.
Halftime: The automated report generated in 38 seconds. Marco reviewed it on the iPad while walking to the dressing room. He later said: "Having the numbers in front of me while talking to the players---that's exactly what I needed."
Minute 67: Dr. Reyes flagged that the left midfielder had reached 58% of his match load limit. Combined with the match score (leading 1-0), they recommended a substitution. The player was replaced at minute 72.
Minute 83: The dashboard showed the opponent formation had shifted to 3-4-3 (their "chasing the game" pattern). This confirmed pre-match scouting and the coaching staff adjusted accordingly.
Post-Match Feedback
Marco: "This is the first time I've felt like the data was actually with me during the match rather than something I get the next day. The formation alerts were the most useful thing."
Elena: "The gap metric on the pitch is brilliant. I could see it and immediately knew what to tell the players. Next step: can we add the opponent's pressing zones?"
Dr. Reyes: "The physical load table worked perfectly. I want to add heart rate data next---can we integrate the GPS vests?"
Lessons Learned
What Worked
- Starting with stakeholder interviews. Understanding that Marco wanted "pictures not numbers" fundamentally shaped the design.
- Keeping the architecture simple. A Python backend + web frontend was sufficient for v1. No Kafka, no Kubernetes, no cloud infrastructure.
- Testing with real users. The youth team match test revealed usability issues that would have been invisible in a lab setting.
- The halftime report. Automating the halftime summary freed Sofia to provide live commentary during the second half rather than frantically compiling statistics.
What Could Be Improved
- WebSocket stability. The reconnection logic worked, but there was a 3-second gap during one reconnection event. A more robust connection management layer (perhaps using Server-Sent Events as a fallback) would improve reliability.
- Formation detection accuracy. The template-based approach struggled with hybrid formations. A machine learning approach trained on more data would be more robust.
- Single point of failure. Sofia's laptop was the entire backend. If it failed, the system was lost. A redundant instance running on a second device would mitigate this.
- Offline capability. When the network briefly dropped at minute 44, the dashboard froze. Caching the last known state and displaying a "data stale" indicator would be better than a frozen screen.
Cost Analysis
| Item | Cost |
|---|---|
| Tracking data API (already contracted) | $0 incremental |
| Sofia's development time (6 weeks) | Part of existing salary |
| Mobile hotspot data plan | $50/month |
| iPad Pro (already owned) | $0 |
| Cloud hosting for dev/testing | $30 |
| Total incremental cost | ~$80 |
This demonstrates that meaningful real-time analytics capability is achievable even for resource-constrained clubs. The investment is primarily in human expertise and time, not infrastructure.
Technical Specifications
System Requirements
| Component | Specification |
|---|---|
| Backend | Python 3.10+, FastAPI, NumPy, SciPy |
| Frontend | HTML5, CSS3, JavaScript, Plotly.js |
| Database | SQLite 3.x |
| Network | 10+ Mbps sustained (5 Mbps minimum) |
| iPad | iOS 16+, Safari browser |
| Laptop | 8+ GB RAM, 4+ CPU cores |
Performance Metrics
| Metric | Target | Achieved |
|---|---|---|
| End-to-end latency | < 3,000 ms | 850 ms (mean) |
| Dashboard refresh rate | 1 Hz | 1 Hz |
| Formation detection accuracy | > 80% | 84% |
| Halftime report generation | < 60 s | 38 s |
| System uptime | > 99% | 99.6% (1 disconnect event) |
Discussion Questions
- What additional features would you prioritize for v1.1 of Sofia's dashboard? Justify your choices based on the coaching staff's feedback.
- How would you redesign the architecture if the club wanted to deploy the system for away matches as well?
- Compare Sofia's lightweight approach with the full infrastructure described in Case Study 1. At what point does a club need to transition from the simple approach to the more complex one?
- How would you handle the situation where the tracking data provider's API goes down mid-match? Design a degraded-mode strategy.
- Sofia was the sole developer and the match-day operator. What are the risks of this single-person dependency, and how could the club mitigate them?