CricInfo
Observer for live score updates pushed to every connected client. State pattern manages match lifecycle transitions from NOT_STARTED through IN_PROGRESS and INNINGS_BREAK to COMPLETED.
Key Abstractions
Top-level orchestrator holding teams, innings, toss result, lifecycle state, and observer management
Named squad of players with a batting order
Individual with accumulated batting stats (runs, balls faced) and bowling stats (wickets, runs conceded)
One team's batting session containing overs, running total, and wicket count
Collection of up to 6 legal deliveries bowled by one bowler
Live score tracker that computes batting card, bowling card, and summary from raw ball data
Observer interface for pushing live updates to subscribers: website, mobile app, TV overlay
Class Diagram
The Key Insight
Cricket has a natural hierarchy: match contains innings, innings contain overs, overs contain balls. When you design the scoring system, the temptation is to store running totals at each level. Innings knows its total runs. Over tracks its own count. Seems clean.
It is a trap. Every time you store a derived total, you create a place where data can drift out of sync. Miss an update after an extra? Scoreboard shows the wrong number. Retroactively correct a wide that was actually a no-ball? You need to fix totals at every level of the hierarchy.
Instead, let Ball be the single source of truth. Every aggregate, runs, wickets, batting strike rate, bowling economy, computes by walking the ball records. For a cricket match with at most 600 deliveries, that computation is trivially fast. And you never deal with stale data.
Layer the observer pattern on top so every ball event pushes live updates to every connected client: the website, the mobile app, the TV overlay, and push notifications. The match does not know who is listening. It just broadcasts.
Requirements
Functional
- Model a cricket match with two teams, innings, overs, and ball-by-ball scoring
- Track runs, wickets, and extras (wides, no-balls, byes, leg-byes) per delivery
- Compute live scorecard: batting card, bowling card, innings summary
- Push live score updates, wicket alerts, and over completion events to all observers
- Enforce match lifecycle transitions: NOT_STARTED to IN_PROGRESS to INNINGS_BREAK to COMPLETED
Non-Functional
- Scorecard computes from raw ball data on every call. No stale cached aggregates.
- Observer-based updates for multiple consumers without polling
- State machine prevents invalid lifecycle transitions like COMPLETED back to IN_PROGRESS
- Clean separation between data model (Ball, Over, Innings) and presentation (Scorecard)
Design Decisions
Why compute totals from balls instead of caching?
Cricket has retroactive corrections. Umpire reviews can overturn a decision. Scoring errors happen under pressure. If totals are cached at every level, a correction means hunting down and updating every cached value up the chain. When totals derive from balls, you fix one ball record and every query returns the right number. For a T20 with 240 legal deliveries, computing a sum on the fly takes microseconds.
Why a state machine for match lifecycle?
Without explicit state transitions, nothing prevents code from calling startInnings() on a completed match. Or completing a match that never started. A state machine with can_transition_to() validation catches these bugs at runtime with a clear error message instead of silently corrupting match data.
Why Observer for live updates?
A live cricket match feeds data to the stadium scoreboard, the TV overlay, the website, the mobile app, and push notifications at the same time. Polling from each consumer wastes bandwidth and introduces inconsistent delay. Observer pushes the update once and every listener gets it at the same moment. Adding a new consumer, like an analytics pipeline, means registering one more observer. No changes to the CricketMatch class.
Why separate Scorecard from Innings?
Innings manages raw ball data. Scorecard formats that data into human-readable cards and summaries. Mixing them means the Innings class needs to know about display formatting, column alignment, and string building. Different responsibility. Keeping them separate also means you can have multiple Scorecard implementations (compact for mobile, detailed for TV, simplified for notifications) without touching the data model.
Interview Follow-ups
- "How would you add DRS (Decision Review System)?" Introduce a
Reviewobject associated with a Ball. If the review overturns the decision, update the Ball's wicket field. Since totals derive from balls, the scorecard auto-corrects on the next query. - "How would you support different formats (T20, ODI, Test)?" Add a
MatchFormatconfig that controls innings count, max overs, and powerplay rules. CricketMatch reads these constraints for format-specific enforcement. - "How would you persist ball-by-ball data for replay?" Event sourcing. Each Ball is an event appended to a log. Replaying the log rebuilds the entire match state. This also enables ball-by-ball replay for highlights.
- "How would you handle live commentary alongside scores?" Commentary is another observer. It receives ball events and generates text descriptions. Keeps commentary decoupled from scoring logic entirely.
Code Implementation
1 from __future__ import annotations
2 from enum import Enum
3 from abc import ABC, abstractmethod
4 from dataclasses import dataclass
5 from typing import Optional
6
7
8 class MatchState(Enum):
9 NOT_STARTED = "not_started"
10 IN_PROGRESS = "in_progress"
11 INNINGS_BREAK = "innings_break"
12 COMPLETED = "completed"
13
14 def can_transition_to(self, target: "MatchState") -> bool:
15 valid = {
16 MatchState.NOT_STARTED: [MatchState.IN_PROGRESS],
17 MatchState.IN_PROGRESS: [MatchState.INNINGS_BREAK, MatchState.COMPLETED],
18 # A match can finish straight from the innings break (second innings ended).
19 MatchState.INNINGS_BREAK: [MatchState.IN_PROGRESS, MatchState.COMPLETED],
20 MatchState.COMPLETED: [],
21 }
22 return target in valid[self]
23
24
25 class ExtraType(Enum):
26 NONE = "none"
27 WIDE = "wide"
28 NO_BALL = "no_ball"
29 BYE = "bye"
30 LEG_BYE = "leg_bye"
31
32
33 class WicketType(Enum):
34 BOWLED = "bowled"
35 CAUGHT = "caught"
36 LBW = "lbw"
37 RUN_OUT = "run_out"
38 STUMPED = "stumped"
39
40
41 class Player:
42 def __init__(self, name: str):
43 self.name = name
44 self.runs_scored = 0
45 self.balls_faced = 0
46 self.wickets_taken = 0
47 self.runs_conceded = 0
48 self.balls_bowled = 0
49
50 @property
51 def strike_rate(self) -> float:
52 return (self.runs_scored / self.balls_faced * 100) if self.balls_faced > 0 else 0.0
53
54 @property
55 def economy(self) -> float:
56 overs = self.balls_bowled / 6
57 return (self.runs_conceded / overs) if overs > 0 else 0.0
58
59 def __str__(self) -> str:
60 return self.name
61
62
63 class Team:
64 def __init__(self, name: str, players: list[Player]):
65 self.name = name
66 self.players = players
67
68 def __str__(self) -> str:
69 return self.name
70
71
72 @dataclass
73 class Wicket:
74 wicket_type: WicketType
75 batsman_out: Player
76 fielder: Optional[Player] = None
77
78 def __str__(self) -> str:
79 if self.fielder:
80 return f"{self.wicket_type.value} ({self.fielder.name})"
81 return self.wicket_type.value
82
83
84 @dataclass
85 class Ball:
86 """Atomic scoring unit. Every aggregate computes upward from here."""
87 batsman: Player
88 bowler: Player
89 runs_scored: int = 0
90 extra_type: ExtraType = ExtraType.NONE
91 extra_runs: int = 0
92 wicket: Optional[Wicket] = None
93
94 def is_legal_delivery(self) -> bool:
95 return self.extra_type not in (ExtraType.WIDE, ExtraType.NO_BALL)
96
97 def total_runs(self) -> int:
98 return self.runs_scored + self.extra_runs
99
100 def __str__(self) -> str:
101 parts = []
102 if self.extra_type != ExtraType.NONE:
103 parts.append(self.extra_type.value)
104 if self.runs_scored > 0:
105 parts.append(f"{self.runs_scored} run{'s' if self.runs_scored > 1 else ''}")
106 if self.wicket:
107 parts.append(f"WICKET! {self.wicket.batsman_out.name} {self.wicket}")
108 if not parts:
109 parts.append("dot ball")
110 return ", ".join(parts)
111
112
113 class Over:
114 def __init__(self, over_number: int, bowler: Player):
115 self.over_number = over_number
116 self.bowler = bowler
117 self._balls: list[Ball] = []
118
119 def add_ball(self, ball: Ball) -> None:
120 self._balls.append(ball)
121 ball.bowler.runs_conceded += ball.total_runs()
122 if ball.is_legal_delivery():
123 ball.bowler.balls_bowled += 1
124 if ball.wicket:
125 ball.bowler.wickets_taken += 1
126
127 @property
128 def legal_deliveries(self) -> int:
129 return sum(1 for b in self._balls if b.is_legal_delivery())
130
131 def is_complete(self) -> bool:
132 return self.legal_deliveries >= 6
133
134 @property
135 def runs(self) -> int:
136 return sum(b.total_runs() for b in self._balls)
137
138 @property
139 def balls(self) -> list[Ball]:
140 return list(self._balls)
141
142 def __str__(self) -> str:
143 ball_str = " ".join(str(b.total_runs()) for b in self._balls)
144 return f"Over {self.over_number}: {ball_str} ({self.runs} runs)"
145
146
147 class Innings:
148 def __init__(self, batting_team: Team, bowling_team: Team, innings_number: int):
149 self.batting_team = batting_team
150 self.bowling_team = bowling_team
151 self.innings_number = innings_number
152 self._overs: list[Over] = []
153 self._current_over: Optional[Over] = None
154 self._wickets = 0
155
156 def start_over(self, bowler: Player) -> Over:
157 over_num = len(self._overs) + 1
158 self._current_over = Over(over_num, bowler)
159 self._overs.append(self._current_over)
160 return self._current_over
161
162 def add_ball(self, ball: Ball) -> None:
163 if self._current_over is None:
164 raise RuntimeError("No over in progress. Call start_over() first.")
165 self._current_over.add_ball(ball)
166 if ball.is_legal_delivery():
167 ball.batsman.balls_faced += 1
168 ball.batsman.runs_scored += ball.runs_scored
169 if ball.wicket:
170 self._wickets += 1
171
172 @property
173 def total_runs(self) -> int:
174 return sum(over.runs for over in self._overs)
175
176 @property
177 def wickets(self) -> int:
178 return self._wickets
179
180 @property
181 def overs_completed(self) -> str:
182 completed = sum(1 for o in self._overs if o.is_complete())
183 current_balls = 0
184 if self._current_over and not self._current_over.is_complete():
185 current_balls = self._current_over.legal_deliveries
186 if current_balls > 0:
187 return f"{completed}.{current_balls}"
188 return str(completed)
189
190 @property
191 def overs(self) -> list[Over]:
192 return list(self._overs)
193
194 @property
195 def current_over(self) -> Optional[Over]:
196 return self._current_over
197
198
199 class MatchObserver(ABC):
200 """Observer interface for live score consumers."""
201
202 @abstractmethod
203 def on_score_update(self, innings: Innings, ball: Ball) -> None: ...
204
205 @abstractmethod
206 def on_wicket(self, innings: Innings, ball: Ball) -> None: ...
207
208 @abstractmethod
209 def on_over_complete(self, innings: Innings, over: Over) -> None: ...
210
211 @abstractmethod
212 def on_state_change(self, old_state: MatchState, new_state: MatchState) -> None: ...
213
214
215 class ConsoleMatchObserver(MatchObserver):
216 def __init__(self, name: str):
217 self._name = name
218
219 def on_score_update(self, innings: Innings, ball: Ball) -> None:
220 print(f" [{self._name}] {innings.batting_team}: "
221 f"{innings.total_runs}/{innings.wickets} "
222 f"({innings.overs_completed} ov) | {ball}")
223
224 def on_wicket(self, innings: Innings, ball: Ball) -> None:
225 print(f" [{self._name}] WICKET! {ball.wicket.batsman_out.name} "
226 f"is out {ball.wicket}")
227
228 def on_over_complete(self, innings: Innings, over: Over) -> None:
229 print(f" [{self._name}] End of {over} | "
230 f"Score: {innings.total_runs}/{innings.wickets}")
231
232 def on_state_change(self, old_state: MatchState, new_state: MatchState) -> None:
233 print(f" [{self._name}] Match state: {old_state.value} -> {new_state.value}")
234
235
236 class Scorecard:
237 """Stateless. Computes everything from raw ball data on each call."""
238
239 @staticmethod
240 def summary(innings: Innings) -> str:
241 return (f"{innings.batting_team.name}: "
242 f"{innings.total_runs}/{innings.wickets} "
243 f"({innings.overs_completed} overs)")
244
245 @staticmethod
246 def batting_card(innings: Innings) -> str:
247 lines = [f"{'Batsman':<20} {'R':>4} {'B':>4} {'SR':>7}"]
248 lines.append("-" * 38)
249 seen = set()
250 for over in innings.overs:
251 for ball in over.balls:
252 p = ball.batsman
253 if p.name not in seen:
254 seen.add(p.name)
255 lines.append(
256 f"{p.name:<20} {p.runs_scored:>4} "
257 f"{p.balls_faced:>4} {p.strike_rate:>7.1f}"
258 )
259 return "\n".join(lines)
260
261 @staticmethod
262 def bowling_card(innings: Innings) -> str:
263 lines = [f"{'Bowler':<20} {'O':>4} {'R':>4} {'W':>4} {'Econ':>7}"]
264 lines.append("-" * 42)
265 seen = set()
266 for over in innings.overs:
267 b = over.bowler
268 if b.name not in seen:
269 seen.add(b.name)
270 overs_display = f"{b.balls_bowled // 6}.{b.balls_bowled % 6}"
271 lines.append(
272 f"{b.name:<20} {overs_display:>4} "
273 f"{b.runs_conceded:>4} {b.wickets_taken:>4} "
274 f"{b.economy:>7.1f}"
275 )
276 return "\n".join(lines)
277
278
279 class CricketMatch:
280 def __init__(self, team1: Team, team2: Team, toss_winner: Team):
281 self.teams = [team1, team2]
282 self.toss_winner = toss_winner
283 self._innings: list[Innings] = []
284 self._current_innings: Optional[Innings] = None
285 self._state = MatchState.NOT_STARTED
286 self._observers: list[MatchObserver] = []
287 self._scorecard = Scorecard()
288
289 @property
290 def state(self) -> MatchState:
291 return self._state
292
293 @property
294 def scorecard(self) -> Scorecard:
295 return self._scorecard
296
297 @property
298 def innings(self) -> list[Innings]:
299 return list(self._innings)
300
301 def add_observer(self, observer: MatchObserver) -> None:
302 self._observers.append(observer)
303
304 def _transition_to(self, new_state: MatchState) -> None:
305 if not self._state.can_transition_to(new_state):
306 raise RuntimeError(
307 f"Invalid transition: {self._state.value} -> {new_state.value}"
308 )
309 old_state = self._state
310 self._state = new_state
311 for obs in self._observers:
312 obs.on_state_change(old_state, new_state)
313
314 def start_match(self) -> None:
315 self._transition_to(MatchState.IN_PROGRESS)
316
317 def start_innings(self, batting_team: Team, bowling_team: Team) -> Innings:
318 if self._state == MatchState.NOT_STARTED:
319 self._transition_to(MatchState.IN_PROGRESS)
320 elif self._state == MatchState.INNINGS_BREAK:
321 self._transition_to(MatchState.IN_PROGRESS)
322 innings_num = len(self._innings) + 1
323 innings = Innings(batting_team, bowling_team, innings_num)
324 self._innings.append(innings)
325 self._current_innings = innings
326 print(f"\n=== Innings {innings_num}: {batting_team.name} batting ===")
327 return innings
328
329 def record_ball(self, ball: Ball) -> None:
330 if self._current_innings is None:
331 raise RuntimeError("No innings in progress")
332 self._current_innings.add_ball(ball)
333
334 for obs in self._observers:
335 obs.on_score_update(self._current_innings, ball)
336 if ball.wicket:
337 obs.on_wicket(self._current_innings, ball)
338
339 cur_over = self._current_innings.current_over
340 if cur_over and cur_over.is_complete():
341 for obs in self._observers:
342 obs.on_over_complete(self._current_innings, cur_over)
343
344 def end_innings(self) -> None:
345 self._transition_to(MatchState.INNINGS_BREAK)
346 self._current_innings = None
347
348 def complete_match(self) -> None:
349 self._transition_to(MatchState.COMPLETED)
350
351
352 if __name__ == "__main__":
353 # Create players
354 rohit = Player("Rohit Sharma")
355 virat = Player("Virat Kohli")
356 bumrah = Player("Jasprit Bumrah")
357
358 warner = Player("David Warner")
359 smith = Player("Steve Smith")
360 starc = Player("Mitchell Starc")
361 cummins = Player("Pat Cummins")
362
363 india = Team("India", [rohit, virat, bumrah])
364 australia = Team("Australia", [warner, smith, starc, cummins])
365
366 # Set up match with observer
367 match = CricketMatch(india, australia, toss_winner=india)
368 match.add_observer(ConsoleMatchObserver("TV Overlay"))
369
370 # First innings: India batting
371 innings1 = match.start_innings(india, australia)
372 innings1.start_over(starc)
373
374 print("\nOver 1 (Starc bowling to Rohit):")
375 match.record_ball(Ball(rohit, starc, runs_scored=4))
376 match.record_ball(Ball(rohit, starc, runs_scored=0))
377 match.record_ball(Ball(rohit, starc, runs_scored=1))
378 match.record_ball(Ball(virat, starc, runs_scored=0,
379 extra_type=ExtraType.WIDE, extra_runs=1))
380 match.record_ball(Ball(virat, starc, runs_scored=6))
381 match.record_ball(Ball(virat, starc, runs_scored=0,
382 wicket=Wicket(WicketType.CAUGHT, virat, cummins)))
383 match.record_ball(Ball(rohit, starc, runs_scored=2))
384
385 # Second over
386 innings1.start_over(cummins)
387 print("\nOver 2 (Cummins bowling to Rohit):")
388 match.record_ball(Ball(rohit, cummins, runs_scored=0))
389 match.record_ball(Ball(rohit, cummins, runs_scored=4))
390 match.record_ball(Ball(rohit, cummins, runs_scored=1))
391 match.record_ball(Ball(rohit, cummins, runs_scored=0,
392 extra_type=ExtraType.NO_BALL, extra_runs=1))
393 match.record_ball(Ball(rohit, cummins, runs_scored=2))
394 match.record_ball(Ball(rohit, cummins, runs_scored=0))
395 match.record_ball(Ball(rohit, cummins, runs_scored=1))
396
397 # End first innings
398 match.end_innings()
399
400 # Display scorecard
401 scorecard = match.scorecard
402 print("\n" + "=" * 42)
403 print("SCORECARD")
404 print("=" * 42)
405 print(scorecard.summary(innings1))
406 print()
407 print(scorecard.batting_card(innings1))
408 print()
409 print(scorecard.bowling_card(innings1))
410
411 print(f"\nTotal runs: {innings1.total_runs}")
412 print(f"Wickets: {innings1.wickets}")
413 print(f"Overs: {innings1.overs_completed}")
414 print(f"Match state: {match.state.value}")
415
416 # Verify state machine
417 assert innings1.wickets == 1
418 assert match.state == MatchState.INNINGS_BREAK
419
420 # Start second innings
421 innings2 = match.start_innings(australia, india)
422 assert match.state == MatchState.IN_PROGRESS
423 print(f"\nSecond innings started. Match state: {match.state.value}")
424
425 # Complete match
426 match.end_innings()
427 match.complete_match()
428 assert match.state == MatchState.COMPLETED
429 print(f"Match completed. Final state: {match.state.value}")
430 print("All assertions passed.")Common Mistakes
- ✗Caching total runs as a mutable field on Innings. One missed update and the scoreboard silently shows wrong numbers.
- ✗Using string comparisons for match state transitions. An enum with a state machine prevents invalid jumps like COMPLETED to IN_PROGRESS.
- ✗Putting all scoring logic inside the Match class. Match should delegate to Innings, which delegates to Over. Keep each level focused.
- ✗Treating extras as regular runs. Wides and no-balls have different rules: a wide does not count as a legal delivery, so the over does not advance.
Key Points
- ✓Observer pattern pushes live score updates to website, mobile app, TV overlay, and push notifications simultaneously
- ✓State pattern manages match lifecycle: NOT_STARTED to IN_PROGRESS to INNINGS_BREAK to COMPLETED with explicit transitions
- ✓Scorecard recomputes from raw ball data on every call. No cached totals that can drift out of sync.
- ✓Ball-by-ball event tracking is the atomic unit. Every aggregate (innings total, strike rate, economy) derives from individual deliveries.