Skip to content

Design Cricinfo (live scoreboard)

TL;DR

  • You build an append-only log of Delivery events, a pure MatchProjector that 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 optional Wicket, 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, Innings 1 → * overs, Match 1 → * innings. All four implement ScoreNode, so runs(), wickets() and legal_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, BattingStats per batter and BowlingStats per 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.

code/lld/cricinfo/models.py — the scoring vocabulary
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:

code/lld/cricinfo/models.py — the delivery event
@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:

code/lld/cricinfo/models.py — the composite score tree
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:

code/lld/cricinfo/strategies.py — scoring rules
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.

code/lld/cricinfo/projector.py — the replay
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.

code/lld/cricinfo/services.py — the scorer's service
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.

code/lld/cricinfo/services.py — the live subscribers
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.

  1. ScoreUpdateService._lock (an RLock) 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 an RLock because record_ball calls _settle, which may call _close_innings, all under the same lock.
  2. Readers take no lock. snapshot returns whatever self._snapshot currently 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_innings on the first-innings lead.
  • Super overs: a tied match starts a new ScoreUpdateService with a one-over FormatSpec. 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 ScoreSubscriber that 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:

code/lld/cricinfo/tests/test_cricinfo.py — the correction
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:

code/lld/cricinfo/tests/test_cricinfo.py — readers and the scorer
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.
  • Composite — ScoreNode from 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