Design Cricinfo (live scoreboard)¶
TL;DR¶
- You build an append-only log of
Deliveryevents, a pureMatchProjectorthat replays the log into an immutable match → innings → over → ball tree, and observers that push each new snapshot to scoreboards and commentary feeds. - Three decisions carry the interview: the event stores no position (over and ball numbers are derived, so a correction can re-cut the overs), corrections are replays, not inverses, and readers take no lock because every publish is one rebind of an immutable snapshot.
- Composite, Observer and Factory earn their place. Command earns it too, in its event-sourced form — the log is the command history.
Problem statement¶
"Design the software behind a live cricket scoreboard. A scorer at the ground enters every delivery: runs, extras, wickets. The site shows a live score, a full scorecard with batting and bowling figures, and ball-by-ball commentary, and it pushes updates to anyone watching. Scorers make mistakes and must be able to fix a ball they entered wrongly. Focus on the class model, the scoring flow, and what readers see while the scorer is editing."
Requirements¶
Functional
- Matches with two teams, a venue, a format (T20, ODI, Test, The Hundred) and a status.
- Ball-by-ball scoring: runs off the bat, wides, no-balls, byes and leg byes, and wickets with a dismissal type, bowler and fielder.
- A composite score model: match holds innings, innings holds overs, an over holds balls, and every level answers runs, wickets and balls bowled.
- Batting figures (runs, balls faced, fours, sixes, dismissal) and bowling figures (overs, maidens, runs conceded, wickets), both derived, never typed in.
- Commentary attached to deliveries, and a live subscription so scoreboards and feeds are pushed to rather than polling.
- Corrections: undo the last ball, and fix a ball entered several deliveries ago.
- Match status through the innings break to a result; a tournament points table.
Non-functional and constraints
- A correction must never leave the scorecard half-updated, and a reader must never see a half-applied ball.
- Readers vastly outnumber the one scorer, so reads must not block on the write path.
- In-memory, single process; deterministic and testable with an injected clock and ID generator.
Out of scope: the transport that pushes updates to browsers (that is the HLD question), video, fantasy scoring, DRS, rain rules.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Can the scorer edit a ball from ten deliveries ago? | Yes, and this is the whole design. correct_ball(innings, index, delivery) replaces one event and replays. |
| Does a wide count as a ball of the over? | No. Neither does a no-ball. That single rule is why over numbers cannot be stored on the event. |
| Who owns the runs on a leg bye? | Extras, not the batter, and the bowler is not charged. Each BallType answers those questions itself. |
| Are the scorecards stored or computed? | Computed, on every write. A T20 innings is ~130 events, so a full replay is microseconds; storage would be a second source of truth to keep in sync. |
| How many writers? | One scorer per match. The lock exists to make each write atomic, not to scale writes. |
| Do readers see the very latest ball? | They see the last published snapshot. It is always a whole ball behind at worst, never a torn one. |
| Do we model free hits and follow-ons? | Not now. ScoringRules is where they land, which is the answer that matters. |
Core entities and relationships¶
- Delivery — the event the scorer types: striker, bowler, runs,
BallType, an optionalWicket, commentary and a timestamp. It carries no over or ball number. - Ball — the projection of one delivery: the same event plus where the replay decided it landed (over number, position in over, batter runs, extras). The leaf of the composite.
- Over
1 → *balls, Innings1 → *overs, Match1 → *innings. All four implementScoreNode, soruns(),wickets()andlegal_balls()mean the same thing at every level. - BallType —
LEGAL,WIDE,NO_BALL,BYE,LEG_BYE, each answering five questions about itself (penalty, legal, faced, credits the batter, charges the bowler). - Team
1 → *Player; Wicket — dismissal type plus batter, bowler and fielder. - Scorecard — one innings rendered: totals, extras,
BattingStatsper batter andBowlingStatsper bowler. - MatchSnapshot — a version number, the projected
Match, the scorecards and the commentary, frozen. This is the only thing readers ever touch. - MatchProjector — the pure replay. ScoreUpdateService — the log, the lock, the status machine and the observer fan-out.
- ScoringRules and FormatSpec / FormatFactory — how long an over is and when an innings ends. PointsRule and PointsTable — the tournament side.
Class diagram¶
The composite: one interface from a single ball up to the whole match.
classDiagram
class ScoreNode {
<<abstract>>
+runs() int
+wickets() int
+legal_balls() int
+summary() str
+overs_bowled(balls_per_over) str
}
class Ball {
+delivery: Delivery
+over_number: int
+position_in_over: int
+batter_runs: int
+extras: int
+label() str
}
class Over {
+number: int
+bowler_id: str
+balls: tuple
+is_maiden(balls_per_over) bool
}
class Innings {
+number: int
+batting_team_id: str
+max_overs: int
+target: int
+closed: bool
}
class Match {
+id: str
+format: MatchFormat
+venue: str
+status: MatchStatus
}
class Delivery {
+id: str
+striker_id: str
+bowler_id: str
+runs: int
+ball_type: BallType
+wicket: Wicket
+commentary: str
}
class Wicket {
+dismissal: DismissalType
+batter_id: str
+bowler_id: str
+describe(bowler, fielder) str
}
class BallType {
<<enumeration>>
LEGAL
WIDE
NO_BALL
BYE
LEG_BYE
}
class Team {
+id: str
+name: str
+players: tuple
}
class Player {
+id: str
+name: str
}
ScoreNode <|.. Ball
ScoreNode <|.. Over
ScoreNode <|.. Innings
ScoreNode <|.. Match
Match "1" *-- "many" Innings
Innings "1" *-- "many" Over
Over "1" *-- "many" Ball
Ball --> Delivery : projects
Delivery --> BallType
Delivery --> "0..1" Wicket
Team "1" *-- "many" Player
The write path: one service, one pure projector, three subscribers, two strategies.
classDiagram
class ScoreUpdateService {
-_lock: RLock
-_logs: List~Delivery~
-_snapshot: MatchSnapshot
+record_ball(striker_id, bowler_id, runs, ball_type, wicket, commentary) MatchSnapshot
+undo_last_ball() MatchSnapshot
+correct_ball(innings_number, index, corrected) MatchSnapshot
+start_innings(batting_team_id, target) MatchSnapshot
+snapshot MatchSnapshot
+subscribe(subscriber)
}
class MatchProjector {
+project(innings_setups, logs, status, version) tuple
+team(team_id) Team
}
class MatchSnapshot {
+version: int
+match: Match
+scorecards: tuple
+commentary: tuple
+headline() str
}
class Scorecard {
+runs: int
+wickets: int
+overs: str
+extras: int
+headline() str
}
class BattingStats {
+runs: int
+balls_faced: int
+strike_rate() float
}
class BowlingStats {
+legal_balls: int
+wickets: int
+economy(balls_per_over) float
}
class ScoreSubscriber {
<<interface>>
+on_update(snapshot)
}
class LiveScoreBoard {
+render() str
}
class CommentaryFeed {
+latest(count) List~str~
}
class WicketAlert {
+alerts: List~str~
}
class ScoringRules {
<<interface>>
+balls_per_over: int
+is_innings_complete(wickets, legal_balls, max_overs) bool
}
class StandardRules
class HundredRules
class FormatSpec {
+max_overs: int
+innings_per_match: int
}
class FormatFactory {
+create(match_format) FormatSpec
}
class PointsTable {
+record(home_id, away_id, winner_id, no_result)
+standings() List~PointsRow~
}
class PointsRule {
<<interface>>
+points(won, tied, no_result) int
}
ScoreUpdateService --> MatchProjector
ScoreUpdateService --> MatchSnapshot : publishes
ScoreUpdateService --> "many" ScoreSubscriber
MatchSnapshot "1" *-- "many" Scorecard
Scorecard "1" o-- "many" BattingStats
Scorecard "1" o-- "many" BowlingStats
ScoreSubscriber <|.. LiveScoreBoard
ScoreSubscriber <|.. CommentaryFeed
ScoreSubscriber <|.. WicketAlert
MatchProjector --> ScoringRules
ScoringRules <|.. StandardRules
ScoringRules <|.. HundredRules
FormatFactory ..> FormatSpec : creates
FormatSpec --> ScoringRules
PointsTable --> PointsRule
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Composite | ScoreNode → Ball / Over / Innings / Match |
The scoreboard asks runs() of a match, an innings or a single ball with the same call. Rendering "15/1" needs no idea how deep it is standing. |
| Event sourcing + Command | Delivery log, record_ball / undo_last_ball / correct_ball |
The log is the command history and the deliveries are the commands. Undo is "drop and replay", not an inverse — see the warning below for why that distinction is the whole problem. |
| Observer | ScoreSubscriber → LiveScoreBoard, CommentaryFeed, WicketAlert |
The service pushes one immutable snapshot; a scoreboard, a filtering alert and a feed all consume it without the service knowing any of them exist. |
| Strategy | ScoringRules, PointsRule |
HundredRules changes an over from six balls to five and the projector does not move a line. Test points and league points are one class each. |
| Factory Method | FormatFactory.create |
A format is a registry entry mapping to overs, innings count and rules. Adding T10 is one row. |
| Template Method | ScoreNode.summary calling the three abstract methods |
The shape of a score line is fixed in the base; every level supplies only the numbers. |
| Polymorphism over conditionals | BallType properties |
penalty, is_legal, is_faced, credits_batter, charges_bowler live on the enum. The projector has no if wide … elif no_ball … ladder, which is exactly where hand-written scorers rot. |
| Immutable value objects | Delivery, Ball, Scorecard, MatchSnapshot |
Frozen objects are what make lock-free reads correct. A reader holding an old snapshot can never be corrupted by the next ball. |
What was deliberately not used: a reader-writer lock. It is the textbook answer for "many readers, one writer", and it is worse here — readers would still block during a replay, and a replay after a correction is the longest write there is. Publishing an immutable snapshot by a single rebind gives readers zero contention and gives you snapshot isolation for free. Say that trade out loud.
Key flows¶
Record a ball: append, replay, publish, notify. Every write is the same four steps.
sequenceDiagram
autonumber
actor Sc as Scorer
participant S as ScoreUpdateService
participant P as MatchProjector
participant R as ScoringRules
participant B as LiveScoreBoard
participant W as WicketAlert
actor Rd as Reader
Sc->>S: record_ball(striker, bowler, 4, LEGAL)
activate S
S->>S: acquire the writer lock
S->>S: check squads and that the innings is open
S->>S: append Delivery to the innings log
S->>P: project(innings, logs, status, version)
loop every delivery in the log
P->>R: balls_per_over, is_innings_complete
R-->>P: 6, False
end
P-->>S: Match, Scorecards, Commentary
S->>S: rebind _snapshot (one assignment)
deactivate S
S-)B: on_update(snapshot)
S-)W: on_update(snapshot)
Rd->>S: snapshot
S-->>Rd: the frozen v11 object, no lock taken
Match lifecycle. INNINGS_BREAK exists so that "open the next innings" has one legal predecessor instead of a boolean scattered across the service.
stateDiagram-v2
[*] --> SCHEDULED : fixture created
SCHEDULED --> LIVE : start_match
LIVE --> INNINGS_BREAK : innings all out or overs bowled
INNINGS_BREAK --> LIVE : start_innings
LIVE --> COMPLETED : target passed or last innings closed
LIVE --> ABANDONED : rain
INNINGS_BREAK --> ABANDONED : rain
SCHEDULED --> ABANDONED : no toss
COMPLETED --> [*]
ABANDONED --> [*]
Implementation¶
Write the vocabulary first, because in cricket the vocabulary is the rules.
BallType carries five properties instead of five branches in the projector. This is the single highest-leverage decision in the file: every extras rule in the game is one of these five answers.
class MatchFormat(StrEnum):
T20 = "t20"
ODI = "odi"
TEST = "test"
HUNDRED = "hundred"
class MatchStatus(StrEnum):
SCHEDULED = "scheduled"
LIVE = "live"
INNINGS_BREAK = "innings_break"
COMPLETED = "completed"
ABANDONED = "abandoned"
class BallType(StrEnum):
"""Each ball type answers five yes/no questions. Putting them here keeps the
projector free of the ``if wide ... elif no_ball ...`` ladder every naive
scorer grows."""
LEGAL = "legal"
WIDE = "wide"
NO_BALL = "no_ball"
BYE = "bye"
LEG_BYE = "leg_bye"
@property
def penalty(self) -> int:
"""Automatic extra run the fielding side concedes for this delivery."""
return 1 if self in (BallType.WIDE, BallType.NO_BALL) else 0
@property
def is_legal(self) -> bool:
"""Does it count towards the six balls of the over?"""
return self not in (BallType.WIDE, BallType.NO_BALL)
@property
def is_faced(self) -> bool:
"""Does it count as a ball faced by the batter? A wide does not."""
return self is not BallType.WIDE
@property
def credits_batter(self) -> bool:
"""Do the runs run off this delivery belong to the batter or to extras?"""
return self in (BallType.LEGAL, BallType.NO_BALL)
@property
def charges_bowler(self) -> bool:
"""Byes and leg byes are not the bowler's fault."""
return self not in (BallType.BYE, BallType.LEG_BYE)
class DismissalType(StrEnum):
BOWLED = "bowled"
CAUGHT = "caught"
LBW = "lbw"
RUN_OUT = "run_out"
STUMPED = "stumped"
HIT_WICKET = "hit_wicket"
@property
def credited_to_bowler(self) -> bool:
return self not in (DismissalType.RUN_OUT,)
Now the event. Read the docstring aloud in the interview — it explains why there is no over_number field:
@dataclass(frozen=True, slots=True)
class Wicket:
"""How a batter got out. Attached to the delivery it happened on."""
dismissal: DismissalType
batter_id: str
bowler_id: str | None = None
fielder_id: str | None = None
def describe(self, bowler: str | None = None, fielder: str | None = None) -> str:
"""Scorecard notation: ``b Starc``, ``c Smith b Cummins``, ``run out (Jadeja)``."""
if self.dismissal is DismissalType.RUN_OUT:
return f"run out ({fielder})" if fielder else "run out"
if self.dismissal is DismissalType.CAUGHT:
return f"c {fielder or 'fielder'} b {bowler}"
if self.dismissal is DismissalType.STUMPED:
return f"st {fielder or 'keeper'} b {bowler}"
if self.dismissal is DismissalType.LBW:
return f"lbw b {bowler}"
if self.dismissal is DismissalType.HIT_WICKET:
return f"hit wicket b {bowler}"
return f"b {bowler}"
@dataclass(frozen=True, slots=True)
class Delivery:
"""The event the scorer enters. It carries no position.
Over number and ball number are *derived* by the projector, because a
correction that turns a legal ball into a wide moves every later ball into
a different over. Storing the position on the event would make that
correction impossible to express.
"""
id: str
striker_id: str
bowler_id: str
runs: int = 0 # runs run off this delivery, before the type decides who gets them
ball_type: BallType = BallType.LEGAL
wicket: Wicket | None = None
commentary: str = ""
recorded_at: float = 0.0
def __post_init__(self) -> None:
if self.runs < 0:
raise ValidationError("runs cannot be negative")
if self.striker_id == self.bowler_id:
raise ValidationError("a bowler cannot bowl to themselves")
The composite is four small classes over one abstract base. Ball is a frozen dataclass and a ScoreNode, which is what makes "sum the tree" a one-liner at each level:
class ScoreNode(ABC):
"""Composite: a ball, an over, an innings and a match all answer the same
three questions, so the scoreboard treats a leaf and the whole match alike."""
@abstractmethod
def runs(self) -> int: ...
@abstractmethod
def wickets(self) -> int: ...
@abstractmethod
def legal_balls(self) -> int: ...
def summary(self) -> str:
"""Template Method: the shape of a score line is fixed, the numbers are not."""
return f"{self.runs()}/{self.wickets()} ({self.overs_bowled()} ov)"
def overs_bowled(self, balls_per_over: int = 6) -> str:
completed, spare = divmod(self.legal_balls(), balls_per_over)
return f"{completed}.{spare}"
@dataclass(frozen=True, slots=True)
class Ball(ScoreNode):
"""A positioned delivery: the leaf of the composite."""
delivery: Delivery
over_number: int # 1-based
position_in_over: int # 1-based, counts every delivery including wides
batter_runs: int
extras: int
def runs(self) -> int:
return self.batter_runs + self.extras
def wickets(self) -> int:
return 1 if self.delivery.wicket else 0
def legal_balls(self) -> int:
return 1 if self.delivery.ball_type.is_legal else 0
def label(self) -> str:
kind = "" if self.delivery.ball_type is BallType.LEGAL else f" {self.delivery.ball_type}"
return f"{self.over_number}.{self.position_in_over}{kind}: {self.runs()}"
@dataclass(frozen=True, slots=True)
class Over(ScoreNode):
number: int
bowler_id: str
balls: tuple[Ball, ...]
def runs(self) -> int:
return sum(b.runs() for b in self.balls)
def wickets(self) -> int:
return sum(b.wickets() for b in self.balls)
def legal_balls(self) -> int:
return sum(b.legal_balls() for b in self.balls)
def is_maiden(self, balls_per_over: int) -> bool:
charged = sum(b.runs() for b in self.balls if b.delivery.ball_type.charges_bowler)
return charged == 0 and self.legal_balls() == balls_per_over
@dataclass(frozen=True, slots=True)
class Innings(ScoreNode):
number: int
batting_team_id: str
bowling_team_id: str
overs: tuple[Over, ...]
max_overs: int | None = None
target: int | None = None
closed: bool = False
def runs(self) -> int:
return sum(o.runs() for o in self.overs)
def wickets(self) -> int:
return sum(o.wickets() for o in self.overs)
def legal_balls(self) -> int:
return sum(o.legal_balls() for o in self.overs)
def balls(self) -> tuple[Ball, ...]:
return tuple(b for o in self.overs for b in o.balls)
@dataclass(frozen=True, slots=True)
class Match(ScoreNode):
id: str
format: MatchFormat
venue: str
home_team_id: str
away_team_id: str
innings: tuple[Innings, ...]
status: MatchStatus
def runs(self) -> int:
return sum(i.runs() for i in self.innings)
def wickets(self) -> int:
return sum(i.wickets() for i in self.innings)
def legal_balls(self) -> int:
return sum(i.legal_balls() for i in self.innings)
The rules are a Protocol with two members. HundredRules differs from StandardRules by one integer, and that integer changes where every over boundary falls:
class ScoringRules(Protocol):
"""How a format counts. Swap it and the projector needs no edit."""
balls_per_over: int
wickets_per_innings: int
def is_innings_complete(self, wickets: int, legal_balls: int, max_overs: int | None) -> bool:
"""All out, or the quota of overs bowled."""
...
class StandardRules:
"""Six-ball overs, ten wickets: T20, ODI and Test cricket."""
balls_per_over = 6
wickets_per_innings = 10
def is_innings_complete(self, wickets: int, legal_balls: int, max_overs: int | None) -> bool:
if wickets >= self.wickets_per_innings:
return True
return max_overs is not None and legal_balls >= max_overs * self.balls_per_over
def __repr__(self) -> str:
return "StandardRules()"
class HundredRules:
"""The Hundred: five-ball overs, 100 balls a side. Same projector, different arithmetic."""
balls_per_over = 5
wickets_per_innings = 10
def is_innings_complete(self, wickets: int, legal_balls: int, max_overs: int | None) -> bool:
if wickets >= self.wickets_per_innings:
return True
return max_overs is not None and legal_balls >= max_overs * self.balls_per_over
def __repr__(self) -> str:
return "HundredRules()"
The projector is the heart. It holds no state and no lock, which is the property that makes a replay trustworthy: given the same log it produces the same tree, every time.
class MatchProjector:
"""Pure projection. No state, no locks, no partial updates."""
def __init__(self, setup: MatchSetup) -> None:
self._setup = setup
self._rules = setup.spec.rules
self._teams = {setup.home.id: setup.home, setup.away.id: setup.away}
def team(self, team_id: str) -> Team:
try:
return self._teams[team_id]
except KeyError:
raise UnknownPlayerError(f"{team_id} is not playing this match") from None
def project(
self,
innings_setups: Sequence[InningsSetup],
logs: Sequence[Sequence[Delivery]],
status: MatchStatus,
version: int,
) -> tuple[Match, tuple[Scorecard, ...], tuple[Commentary, ...]]:
innings: list[Innings] = []
cards: list[Scorecard] = []
commentary: list[Commentary] = []
for setup, log in zip(innings_setups, logs, strict=True):
one, card, lines = self._project_innings(setup, log)
innings.append(one)
cards.append(card)
commentary.extend(lines)
match = Match(
id=self._setup.match_id,
format=self._setup.spec.format,
venue=self._setup.venue,
home_team_id=self._setup.home.id,
away_team_id=self._setup.away.id,
innings=tuple(innings),
status=status,
)
return match, tuple(cards), tuple(commentary)
def _project_innings(
self, setup: InningsSetup, log: Sequence[Delivery]
) -> tuple[Innings, Scorecard, list[Commentary]]:
batting_team, bowling_team = self.team(setup.batting_team_id), self.team(setup.bowling_team_id)
batters: dict[str, _Batter] = {}
bowlers: dict[str, _Bowler] = {}
overs: list[Over] = []
current: list[Ball] = []
commentary: list[Commentary] = []
over_number = 0
legal_in_over = 0
extras_total = 0
for delivery in log:
kind = delivery.ball_type
if not current:
over_number += 1
batter_runs = delivery.runs if kind.credits_batter else 0
extras = kind.penalty + (0 if kind.credits_batter else delivery.runs)
ball = Ball(delivery, over_number, len(current) + 1, batter_runs, extras)
current.append(ball)
extras_total += extras
batter = self._batter(batters, batting_team, delivery.striker_id)
batter.runs += batter_runs
batter.balls_faced += 1 if kind.is_faced else 0
batter.fours += 1 if kind.credits_batter and delivery.runs == 4 else 0
batter.sixes += 1 if kind.credits_batter and delivery.runs == 6 else 0
bowler = self._bowler(bowlers, bowling_team, delivery.bowler_id)
bowler.legal_balls += 1 if kind.is_legal else 0
bowler.runs_conceded += ball.runs() if kind.charges_bowler else 0
if delivery.wicket is not None:
out = self._batter(batters, batting_team, delivery.wicket.batter_id)
out.dismissal = delivery.wicket.describe(
self._name(bowling_team, delivery.wicket.bowler_id),
self._name(bowling_team, delivery.wicket.fielder_id),
)
if delivery.wicket.dismissal.credited_to_bowler:
bowler.wickets += 1
if delivery.commentary:
commentary.append(
Commentary(over_number, len(current), delivery.commentary, delivery.recorded_at)
)
legal_in_over += 1 if kind.is_legal else 0
if legal_in_over == self._rules.balls_per_over:
overs.append(self._close_over(over_number, current, bowlers))
current, legal_in_over = [], 0
if current:
overs.append(self._close_over(over_number, current, bowlers))
innings = Innings(
number=setup.number,
batting_team_id=setup.batting_team_id,
bowling_team_id=setup.bowling_team_id,
overs=tuple(overs),
max_overs=setup.max_overs,
target=setup.target,
closed=self._rules.is_innings_complete(
sum(o.wickets() for o in overs),
sum(o.legal_balls() for o in overs),
setup.max_overs,
),
)
card = Scorecard(
innings_number=setup.number,
batting_team=batting_team.name,
runs=innings.runs(),
wickets=innings.wickets(),
overs=innings.overs_bowled(self._rules.balls_per_over),
extras=extras_total,
batting=tuple(
BattingStats(pid, b.name, b.runs, b.balls_faced, b.fours, b.sixes, b.dismissal)
for pid, b in batters.items()
),
bowling=tuple(
BowlingStats(pid, w.name, w.legal_balls, w.runs_conceded, w.wickets, w.maidens)
for pid, w in bowlers.items()
),
)
return innings, card, commentary
def _close_over(self, number: int, balls: list[Ball], bowlers: dict[str, _Bowler]) -> Over:
over = Over(number, balls[0].delivery.bowler_id, tuple(balls))
if over.is_maiden(self._rules.balls_per_over):
bowlers[over.bowler_id].maidens += 1
return over
@staticmethod
def _name(team: Team, player_id: str | None) -> str | None:
return team.name_of(player_id) if player_id else None
@staticmethod
def _batter(batters: dict[str, _Batter], team: Team, player_id: str) -> _Batter:
if player_id not in batters:
batters[player_id] = _Batter(team.name_of(player_id))
return batters[player_id]
@staticmethod
def _bowler(bowlers: dict[str, _Bowler], team: Team, player_id: str) -> _Bowler:
if player_id not in bowlers:
bowlers[player_id] = _Bowler(team.name_of(player_id))
return bowlers[player_id]
The service owns the log, the lock and the status machine. Look at _publish: build the new snapshot first, rebind last. That ordering is the whole reader story.
class ScoreUpdateService:
"""One match, one writer lock, and a snapshot readers never block on."""
def __init__(
self,
setup: MatchSetup,
clock: Clock | None = None,
ids: IdGenerator | None = None,
) -> None:
self._setup = setup
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("d")
self._projector = MatchProjector(setup)
self._lock = threading.RLock()
self._innings: list[InningsSetup] = []
self._logs: list[list[Delivery]] = []
self._status = MatchStatus.SCHEDULED
self._result = ""
self._version = 0
self._subscribers: list[ScoreSubscriber] = []
self._snapshot = self._project()
# -- reads: no lock, by construction --------------------------------------
@property
def snapshot(self) -> MatchSnapshot:
"""Readers get the last published snapshot. Immutable, so it cannot tear."""
return self._snapshot
def deliveries(self, innings_number: int) -> tuple[Delivery, ...]:
return tuple(self._log(innings_number))
def subscribe(self, subscriber: ScoreSubscriber) -> None:
subscriber.on_update(self._snapshot)
self._subscribers.append(subscriber)
# -- writes ---------------------------------------------------------------
def start_match(self) -> MatchSnapshot:
with self._lock:
if self._status is not MatchStatus.SCHEDULED:
raise MatchStateError(f"match is {self._status}, not scheduled")
self._status = MatchStatus.LIVE
return self._publish()
def start_innings(self, batting_team_id: str, target: int | None = None) -> MatchSnapshot:
with self._lock:
# The first innings opens on a live match; every later one needs the break.
expected = MatchStatus.INNINGS_BREAK if self._innings else MatchStatus.LIVE
if self._status is not expected:
raise MatchStateError(f"cannot open an innings while the match is {self._status}")
if len(self._innings) >= self._setup.spec.innings_per_match:
raise MatchStateError(f"a {self._setup.spec.format} match has no further innings")
batting = self._projector.team(batting_team_id)
bowling = self._setup.away if batting.id == self._setup.home.id else self._setup.home
self._innings.append(
InningsSetup(
number=len(self._innings) + 1,
batting_team_id=batting.id,
bowling_team_id=bowling.id,
max_overs=self._setup.spec.max_overs,
target=target,
)
)
self._logs.append([])
self._status = MatchStatus.LIVE
return self._publish()
def record_ball(
self,
striker_id: str,
bowler_id: str,
runs: int = 0,
ball_type: BallType = BallType.LEGAL,
wicket: Wicket | None = None,
commentary: str = "",
) -> MatchSnapshot:
"""Append one event to the log, replay, publish. That is the whole write path."""
with self._lock:
setup, log = self._current()
if self._snapshot.match.innings[setup.number - 1].closed:
raise InningsClosedError(f"innings {setup.number} is already complete")
self._require_squad(setup.batting_team_id, striker_id)
self._require_squad(setup.bowling_team_id, bowler_id)
log.append(
Delivery(
id=self._ids.next_id(),
striker_id=striker_id,
bowler_id=bowler_id,
runs=runs,
ball_type=ball_type,
wicket=wicket,
commentary=commentary,
recorded_at=self._clock.now(),
)
)
self._settle(setup)
return self._publish()
def undo_last_ball(self) -> MatchSnapshot:
"""Drop the last event and replay. Nothing is patched, so nothing can drift.
Deliberately *not* routed through ``_current``: the ball a scorer most
often needs back is the one that ended the innings, and by then the
match is in a break rather than live. ``_settle`` reopens it.
"""
with self._lock:
if not self._innings:
raise MatchStateError(f"no innings has been opened (match is {self._status})")
setup, log = self._innings[-1], self._logs[-1]
if not log:
raise MatchNotFoundError(f"innings {setup.number} has no deliveries to undo")
log.pop()
self._settle(setup)
return self._publish()
def correct_ball(self, innings_number: int, index: int, corrected: Delivery) -> MatchSnapshot:
"""Replace one event in the middle of the log and replay the whole innings.
A correction can change whether the ball was legal, which moves every
later delivery into a different over. Only a replay can express that;
an inverse-and-reapply cannot.
"""
with self._lock:
log = self._log(innings_number)
if not 0 <= index < len(log):
raise MatchNotFoundError(f"innings {innings_number} has no delivery at index {index}")
log[index] = replace(corrected, id=log[index].id) # the event id is the audit trail
if innings_number == len(self._innings):
self._settle(self._innings[-1]) # a correction can end -- or un-end -- the innings
return self._publish()
def end_innings(self) -> MatchSnapshot:
"""Declare or close the innings early."""
with self._lock:
setup, _ = self._current()
self._close_innings(setup)
return self._publish()
def abandon(self, reason: str) -> MatchSnapshot:
with self._lock:
self._status = MatchStatus.ABANDONED
self._result = reason
return self._publish()
# -- internals ------------------------------------------------------------
def _current(self) -> tuple[InningsSetup, list[Delivery]]:
if self._status is not MatchStatus.LIVE or not self._innings:
raise MatchStateError(f"no innings is in progress (match is {self._status})")
return self._innings[-1], self._logs[-1]
def _log(self, innings_number: int) -> list[Delivery]:
if not 1 <= innings_number <= len(self._logs):
raise MatchNotFoundError(f"unknown innings {innings_number}")
return self._logs[innings_number - 1]
def _require_squad(self, team_id: str, player_id: str) -> None:
team: Team = self._projector.team(team_id)
if not team.has(player_id):
raise UnknownPlayerError(f"{player_id} is not in {team.name}")
def _settle(self, setup: InningsSetup) -> None:
"""Derive the match status from the replayed innings -- forwards *and* back.
Both directions matter. An undo or a correction can take back the ball
that ended an innings, and a status that only ever moved forward would
leave the match sitting in a break over an innings the replay says is
live again -- the scorecard and the status disagreeing is exactly the
drift that event sourcing is supposed to make impossible.
"""
match, _, _ = self._projector.project(self._innings, self._logs, self._status, self._version)
innings = match.innings[setup.number - 1]
if setup.target is not None and innings.runs() >= setup.target:
self._finish(self._projector.team(setup.batting_team_id).name)
elif innings.closed:
self._close_innings(setup)
elif setup.number == len(self._innings) and self._status in (
MatchStatus.INNINGS_BREAK,
MatchStatus.COMPLETED,
):
self._reopen()
def _reopen(self) -> None:
"""The replay says the last innings is live again, so the match is too."""
self._status = MatchStatus.LIVE
self._result = ""
def _close_innings(self, setup: InningsSetup) -> None:
if len(self._innings) >= self._setup.spec.innings_per_match:
self._finish(self._winner_by_runs())
else:
self._status = MatchStatus.INNINGS_BREAK
def _winner_by_runs(self) -> str:
match, _, _ = self._projector.project(self._innings, self._logs, self._status, self._version)
totals = [(i.runs(), self._projector.team(i.batting_team_id).name) for i in match.innings]
best = max(totals)
return "" if sum(1 for t in totals if t[0] == best[0]) > 1 else best[1]
def _finish(self, winner: str) -> None:
self._status = MatchStatus.COMPLETED
self._result = f"{winner} won" if winner else "match tied"
def _project(self) -> MatchSnapshot:
match, cards, commentary = self._projector.project(
self._innings, self._logs, self._status, self._version
)
return MatchSnapshot(self._version, match, cards, commentary, self._result)
def _publish(self) -> MatchSnapshot:
"""The only writer of ``self._snapshot``: build first, assign last."""
self._version += 1
snapshot = self._project()
self._snapshot = snapshot # one atomic rebind; readers see old or new, never both
for subscriber in self._subscribers:
subscriber.on_update(snapshot)
return snapshot
The three subscribers show the range Observer covers: one keeps the latest, one keeps the projected tail, one fires on a condition.
class ScoreSubscriber(Protocol):
"""Observer interface. Called outside the writer lock, with an immutable snapshot."""
def on_update(self, snapshot: MatchSnapshot) -> None: ...
class LiveScoreBoard:
"""The score strip. Keeps only the latest snapshot; late joiners are not a problem."""
def __init__(self) -> None:
self._snapshot: MatchSnapshot | None = None
def on_update(self, snapshot: MatchSnapshot) -> None:
self._snapshot = snapshot
def render(self) -> str:
return self._snapshot.headline() if self._snapshot else "no play yet"
class CommentaryFeed:
"""Holds the projected commentary, not an append-only tail.
That is the point: after a correction the replay produces different lines,
and a feed that appended blindly would show the wrong ball twice.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._lines: tuple[Commentary, ...] = ()
def on_update(self, snapshot: MatchSnapshot) -> None:
with self._lock:
self._lines = snapshot.commentary
def latest(self, count: int = 3) -> list[str]:
with self._lock:
return [f"{c.over_number}.{c.position_in_over} {c.text}" for c in self._lines[-count:]]
class WicketAlert:
"""Fires only when the wicket count grows — the classic filtering observer."""
def __init__(self) -> None:
self._seen = 0
self.alerts: list[str] = []
def on_update(self, snapshot: MatchSnapshot) -> None:
wickets = snapshot.match.wickets()
if wickets > self._seen:
self.alerts.append(f"WICKET at {snapshot.match.summary()}")
self._seen = wickets
Running python -m lld.cricinfo.demo scores an over, gets a ball wrong, and fixes it:
live: [v10] India 15/1 (1.1 ov) (live)
overs hold [7, 1] deliveries; extras 3
commentary: ['1.4 over midwicket', '2.1 timber']
alerts: ['WICKET at 15/1 (1.1 ov)']
delivery 3 was entered as a wide; it was a legal ball edged for four
after replay: [v11] India 18/1 (1.2 ov) (live)
overs now hold [6, 2] deliveries; extras 2
the reader still holds v10: India 15/1 (1.1 ov)
bat: Rohit 5 (3b) b Cummins
bat: Kohli 11 (5b) not out
bowl: Starc 1.1-0-16-0
bowl: Cummins 0.1-0-0-1
undo the wicket: [v12] India 18/0 (1.1 ov) (live)
points: [('IND', 2), ('AUS', 0)]
The two lines that matter are overs hold [7, 1] and overs now hold [6, 2]. Correcting the third delivery from a wide to a legal ball made the over one delivery shorter, so the eighth delivery moved from over 1 into over 2, and the wicket's commentary line moved from 2.1 with it. No inverse operation can express that; only a replay can.
Concurrency and edge cases¶
Which lock protects what. There is one lock and one lock-free path, and explaining why is the interview.
ScoreUpdateService._lock(anRLock) guards the delivery logs, the innings list, the status and the version counter. Every write takes it:record_ball,undo_last_ball,correct_ball,start_innings. It is anRLockbecauserecord_ballcalls_settle, which may call_close_innings, all under the same lock.- Readers take no lock.
snapshotreturns whateverself._snapshotcurrently points at. Because the write path builds a completely new frozen object and then rebinds the attribute in one statement, a reader gets either the old score or the new one. There is no window in which it can see the runs updated but not the ball list, and a reader that keeps its snapshot for a whole render sees one consistent match, however many balls are scored underneath it.
That is a copy-on-write publish, and it beats a reader-writer lock here for a concrete reason: a correction replays the whole innings, so it is the slowest write in the system, and a reader-writer lock would make every reader wait for exactly that.
Cost of the replay. A T20 innings is about 130 deliveries; a full replay walks the log once and allocates a few hundred small objects, which is microseconds — the same order as the ~3 µs it takes to read 1 MB sequentially from memory. Rebuilding is cheaper than maintaining a second, patchable copy of the scorecard and keeping it consistent, and it is the only version that survives a correction.
Correcting a mis-entered ball. correct_ball replaces one event in place, keeping its id as the audit trail, then replays. Because over and ball positions are derived, a correction that changes legality re-cuts every later over. The test asserts exactly that: [7, 1] deliveries per over becomes [6, 2]. undo_last_ball is the same mechanism with a pop.
Innings and match transitions. After every write the service replays, asks the rules whether the innings is complete, and compares the total against the target. Passing a target ends the match mid-over; running out of wickets or overs ends the innings and moves to INNINGS_BREAK. The status is derived from the replay in both directions, which is the part candidates skip: undoing or correcting the ball that ended an innings reopens it, and a status that only ever moved forward would leave the match in a break over an innings the scorecard says is live. undo_last_ball therefore works during a break, because the ball a scorer most often needs back is exactly the one that closed the innings. start_innings demands INNINGS_BREAK for any innings after the first, so you cannot open a second innings while the first is live.
Other edge cases handled: wides and no-balls not advancing the over, byes and leg byes not charged to the bowler, a no-ball counting as a ball faced while a wide does not, run-outs not credited to the bowler, a batter who only appears as a run-out victim still getting a scorecard row, maidens counted only on complete overs with no charged runs, recording a ball for a player who is not in the squad, and recording after the match is over.
Common mistake
Implementing undo as an inverse: "subtract the runs, decrement the ball count". It passes the demo and it is wrong. Correcting a delivery can flip whether it was legal, which shifts every later ball into a different over, changes which bowler owns those overs, and changes whether an over was a maiden. Inverses cannot express a re-cut. Store events, derive positions, and replay.
Extensibility and follow-ups¶
- Free hits: after a no-ball, the next legal delivery cannot dismiss the batter except by run-out. That is a rule about the sequence, which the projector already walks — add a flag it carries forward, and put the toggle on
ScoringRules. - Follow-on and Test cricket:
FormatSpec(TEST)already declares four innings and no over limit. The follow-on is a condition checked in_close_inningson the first-innings lead. - Super overs: a tied match starts a new
ScoreUpdateServicewith a one-overFormatSpec. The service is one match, so a second match is a second object. - Snapshot diffs for the wire: readers currently receive the whole snapshot. Since every snapshot carries a version, sending
(version, changed scorecard rows)is a projection detail and no domain code changes. - Push at scale: millions of concurrent viewers is the HLD question — WebSocket or SSE fan-out, regional edges and a pull-based fallback. See Design a notification system for the delivery machinery.
- Fantasy leagues: another
ScoreSubscriberthat scores points per snapshot; the domain never learns fantasy exists.
Interview tip
When the interviewer says "the scorer entered the wrong ball", do not reach for a mutable Ball object. Ask one question — "can a correction change whether the ball was legal?" — and when the answer is yes, you have earned the event log. That single question is what separates a candidate who has modelled cricket from one who has modelled a spreadsheet.
Tests¶
tests/test_cricinfo.py has 16 cases. The correction test is the one to walk through, because it asserts the over boundaries moved rather than just the total:
def test_correcting_a_wide_moves_every_later_ball_into_a_different_over(clock: FakeClock) -> None:
"""The reason the log stores events, not positions: a correction re-cuts the overs."""
service = make_service(clock)
for _ in range(2):
service.record_ball("rohit", "starc", 1)
service.record_ball("kohli", "starc", 0, BallType.WIDE) # index 2: the mis-entry
for _ in range(5):
service.record_ball("kohli", "starc", 1)
before = service.snapshot
assert [len(o.balls) for o in before.match.innings[0].overs] == [7, 1]
assert (before.match.innings[0].runs(), before.scorecards[0].overs) == (8, "1.1")
wrong = service.deliveries(1)[2]
service.correct_ball(1, 2, replace(wrong, ball_type=BallType.LEGAL, runs=4))
after = service.snapshot
assert [len(o.balls) for o in after.match.innings[0].overs] == [6, 2] # the ball moved over
assert (after.match.innings[0].runs(), after.scorecards[0].overs) == (11, "1.2")
assert after.version > before.version
assert before.match.innings[0].runs() == 8 # the old snapshot is untouched
The concurrency test runs scorers and readers through the same pool and checks that every snapshot a reader saw was internally whole:
def test_readers_never_see_a_torn_snapshot_while_the_scorer_writes(clock: FakeClock) -> None:
"""Four scorers, four readers, one match: every snapshot a reader sees is whole."""
service = make_service(clock)
balls = 40
def score(_: int) -> None:
service.record_ball("rohit", "starc", 1)
def read(_: int) -> bool:
innings = service.snapshot.match.innings[0]
# A torn read would show runs that do not match the balls the snapshot holds.
return innings.runs() == len(innings.balls())
with ThreadPoolExecutor(max_workers=8) as pool:
writers = pool.map(score, range(balls))
readers = list(pool.map(read, range(200)))
list(writers)
assert all(readers)
assert len(service.deliveries(1)) == balls
assert service.snapshot.match.innings[0].runs() == balls
assert service.snapshot.version == balls + 2 # start_match + start_innings + one per ball
The rest cover: an over scored ball by ball with exact batting and bowling figures; all five ball types via parametrize, asserting who gets the runs and whether the over advanced; validation (unknown striker, a bowler from the wrong side, negative runs, opening a second innings too early); the full status walk from SCHEDULED to COMPLETED with a chased target; undo removing a wicket and its alert; undoing the ball that closed an innings reopening it, and a correction that turns the last legal ball into a wide doing the same; HundredRules cutting overs at five balls; and both points rules. Run them with uv run pytest code/lld/cricinfo -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | Can the scorer fix an old ball? Does a wide count towards the over? Are figures stored or derived? Out of scope: the push transport, DRS, rain rules. |
| 5–10 | Entities | Nouns: Match, Innings, Over, Ball, Delivery, Team, Player, Wicket. Note out loud that Delivery and Ball are different things and say why. |
| 10–16 | Class diagram | Draw ScoreNode and hang the four levels off it. Then the service, the projector and the subscribers. |
| 16–22 | Ball types | Write the BallType enum with its five properties. Say "this is where every extras rule lives, so the projector has no branches". |
| 22–32 | The write path | record_ball: lock, validate, append, replay, publish. Then correct_ball, and draw the [7, 1] → [6, 2] example on the board. |
| 32–38 | Readers | Immutable snapshot, one rebind, no reader lock. Contrast with a reader-writer lock and say why it loses. |
| 38–43 | Tests | The correction test's three assertions and the reader/scorer race. |
| 43–45 | Extensions | Free hits, follow-on, super over, and push at scale as the HLD hand-off. |
Related¶
- Composite —
ScoreNodefrom a single ball up to the match - Observer — the snapshot fan-out to boards, feeds and alerts
- Command — the delivery log as a command history, and why undo is a replay
- Memento — the snapshot-versus-log trade this page takes the other side of
- Design a notification system — pushing those snapshots to millions of viewers
- Design Stack Overflow — the same Observer shape without the replay problem