Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 15 – Case studies: platforms and apps

Low-level design: live cricket scorecard

Design a live cricket scorecard in code: deliveries as events, extras and strike by the Laws, overs counted in balls, and updates sent to subscribers.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: Six questions settle the rules of the scorecard · Classes, and why the card is derived · Overs are balls, and the card is a fold of the log · The first complete design, replayed from a ball-by-ball file. With a pass: 3 in-depth parts and 3 interview answers, about 12 minutes.

What you will learn

  • Model matches, innings, overs, balls, batters, bowlers and extras as events and values
  • Update the scorecard ball by ball following the Laws of Cricket for extras, strike and dismissals
  • Store overs as counts of valid balls, and calculate rates from balls
  • Apply a correction by replacing one delivery in the log and folding the log again
  • Publish ball-by-ball updates to subscribers, with versions that survive duplicates and delays

Before you start

What you’ll learn · Before you start

What you will learn

  • Model matches, innings, overs, balls, batters, bowlers and extras as events and values
  • Update the scorecard ball by ball following the Laws of Cricket for extras, strike and dismissals
  • Store overs as counts of valid balls, and calculate rates from balls
  • Apply a correction by replacing one delivery in the log and folding the log again
  • Publish ball-by-ball updates to subscribers, with versions that survive duplicates and delays

Before you start

On this page

After 19.4 overs the Kestrels had 173 runs. Their run rate is 173 × 6 ÷ 118 = 8.80 an over, because 19.4 means 19 overs and 4 balls, and dividing by 19.4 as a decimal gives 8.92, a rate that was never scored. A live cricket scorecard, as a low-level design (LLD) case, turns every delivery into runs, extras, wickets, strike and bowling figures, and stays right when the scorers fix a mistake.

Four decisions shape it. Each delivery is an event in a log, and the scorecard is folded from the log, so a correction is a replay. The end of an over is a fact the scorers record when the umpire calls it, not a count. Overs are stored as balls. And the Laws are rules applied in one class, while scoreboards and alerts hear about each ball as subscribers. Streaming a match to millions of viewers is the system design track’s live cricket streaming case; this model is the scorer’s side.

Not affiliated

The rules come from the Laws of Cricket, which the MCC publishes, and the ICC’s playing conditions for T20 matches. MySmartCoPilot is not affiliated with the MCC, the ICC or any score provider.

Six questions settle the rules of the scorecard

“Design a live cricket scorecard” hides most of its rules in the Laws. The answers below are the ones the classes need:

Question Answer, with the Law
Which format? An innings of at most 20 overs, and at most 4 overs for any bowler
What is a ball of the over? One of six valid ball: A delivery that counts as one of the six balls of an over; a wide or a no ball adds a penalty run and is bowled again, so it is not a valid ball.; wides and no balls do not count (Laws 17.1, 21.16, 22.8)
What do extras: Runs credited to the batting side but to no batter: no-ball and wide penalties and the runs made from them, byes and leg byes. add? A one-run penalty for each wide or no ball (Laws 21.14, 22.6), plus any runs made from them; byes and leg byes go to extras, not to the batter
Who is charged with runs? The bowler, for runs off the bat, wides and no-ball extras (Law 18.10.3)
Who faces next? Odd runs change ends and boundaries do not; each over is bowled from the other end; after bowled, caught, lbw, stumped or hit wicket the new batter takes the striker’s end (Law 18.11.1)
Who hears about each ball? Subscribers such as a scoreboard, alerts and text commentary
  • R1 The log records each over as the umpire called it: its number, its bowler and its deliveries in order.
  • R2 The scorecard (batting, extras, total, fall of wickets and bowling) is derived from the log, never typed.
  • R3 Extras, strike and dismissals follow the Laws, and only the five bowler’s dismissals count for the bowler.
  • R4 No bowler bowls two overs in a row (Law 17.6), nor more than four. R5 An over with other than six valid balls stands as the umpire counted it (Law 17.5.1), with a warning.
  • R6 After every delivery, subscribers receive an update with a version. R7 A correction replaces one delivery in the log, which is then folded again. N1 Balls are integers; “19.4” exists only on screen.

Out of scope: rain-reduced targets, super overs, penalty runs and commentary text; the free hit is the exercise.

Classes, and why the card is derived

  • Ball and Over (values): what the scorers saw. A Ball knows whether it was valid and what it costs the bowler.
  • Card: folds the log, delivery by delivery, and owns R2 to R6. Batter and Bowling: each player’s figures.
  • Update (a value) and the subscribers: who hears about each delivery, and which version they hold.
Class diagram: a Card folds Overs of Balls into Batter and Bowling figures and sends an Update to subscribers after each Ball.Over (a value)+numberas called+bowlerstr+ballstuple[Ball]Ball (a value)+call'' | wide | no ball+bat, byes, leg_byesint+wicket, fielderstr+validbool+debitedintCard+total, wicketsint+overs, ballscalled, in progress+extrasb, lb, w, nb+subscriberslist+play(log)Card+apply(over, ball)+finish(over)Update (a value)+kindball | over+versiondeliveries so farBatter+runs, ballsint+fours, sixesint+hownot out | b Owen | …Bowling+overs, ballscalled, in progress+maidens, runs, wicketsint11..*folds0..*sends0..*one per batter in2..11one per bowler1..*

The live scorecard's class diagram

Text description of the diagram

The diagram shows six classes in UML notation. Each box names a class, then lists its attributes and, below a line, its operations.

  • Over, a value, has the number the umpire called, a bowler and a tuple of Balls. It is composed of one or more Balls (a filled diamond at the Over end).
  • Ball, a value, has a call (none, wide or no ball), runs off the bat, byes and leg byes, and a wicket with a fielder. Its operations are valid, a yes or no, and debited, the runs charged to the bowler.
  • Card has the total and wickets, the overs called and the balls of the over in progress, the extras (byes, leg byes, wides, no balls) and a list of subscribers. Its operations are play(log), returning the Card, apply(over, ball) and finish(over). An arrow labelled "folds" leads from Card to zero or more Overs.
  • An arrow labelled "sends" leads from Card to zero or more Updates. An Update, a value, has a kind (ball or over) and a version, the number of deliveries folded so far.
  • An arrow labelled "one per batter in" leads from Card to 2 to 11 Batters. A Batter has runs, balls faced, fours, sixes and how they were out, such as "not out" or "b Owen".
  • An arrow labelled "one per bowler" leads from Card to one or more Bowling records, each with the overs called, the balls of an over in progress, maidens, runs and wickets.

The card is derived: no screen ever adds 4 to a total, so a delivery entered wrongly is fixed by replacing it and folding again, and every figure moves together. And the end of an over is observed, because the Laws make the umpire’s count final, even when it is wrong.

Checkpoint

Check: The rules the Laws settle · A card derived from the log

  1. Question 1 of 2 The bowler sends a wide, and nobody runs. What changes on the card?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The total gains a penalty run, and the over still has the same number of valid balls

    In the first over of the program the wide took the total from 1 to 2 while the valid balls stayed at 2, and the strike stayed with Bilal.

  2. Question 2 of 2 A scorer typed a four where the batter ran one. How does the model fix the card?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It replaces the delivery in the log and folds the card again, so every figure moves together

    R7 makes a correction a replacement in the log, and the fold recomputes the batting, the bowling, the strike and the total from it.

Overs are balls, and the card is a fold of the log

The display “19.4” is two numbers, overs and balls, so it must never be used as a decimal. The figure shows where the log, the card and the subscribers sit:

Flow: scorers write the log of deliveries; the card is folded from the log and sends versioned updates to subscribers.ScorersLog: overs as called,each with its deliveriesCard(folded from the log)Subscribers:boards, alerts, textrecord a deliveryor an over callfold, ball by ballUpdate (revision, version)correct one delivery:revision 2

The log is written, the card is derived

Text description of the diagram

The diagram is a flow from top to bottom with four parts.

  1. Scorers, drawn as a person, record each delivery or the umpire's call of Over in the log.
  2. The log, drawn as a database, holds the overs as called, each with its deliveries. It is the only thing that is ever written.
  3. An arrow labelled "fold, ball by ball" leads from the log to the Card, which is folded from the log.
  4. An arrow labelled "Update (revision, version)" leads from the Card to the subscribers: boards, alerts and text commentary.
  5. A dashed arrow from the scorers to the log is labelled "correct one delivery: revision 2": a correction writes a new revision of the log, which is folded again.

A short program shows the core of the fold: three overs as the umpire called them, a card that counts runs, wickets and valid balls, and the strike rules. A token is one delivery: . no run, a digit runs off the bat, wd a wide, nb4 a no ball hit for four, W the striker out. All 14 languages printed the same four lines:

One program · 14 languages A scorecard folded from deliveries, with overs as balls

Python 3.14 · main.py

LOG = [
    ". 1 wd 4 nb4 2 1 .",
    "W 1 6 . 2 1",
    "4 1 1",
]  # the overs, as the umpire called them


class Card:
    def __init__(self, batters: list[str]) -> None:
        self.striker, self.other, *self.next_in = batters
        self.runs = self.wickets = self.balls = 0

    def deliver(self, token: str) -> None:
        kind = token.rstrip("0123456789")
        runs = int(token[len(kind) :] or 0)
        if kind in ("wd", "nb"):  # a penalty run, and not a ball of the over
            self.runs += 1
        else:
            self.balls += 1
        self.runs += runs
        if kind == "W":
            self.wickets += 1
            self.striker = self.next_in.pop(0)  # the new batter takes strike
        if runs % 2 == 1:  # odd runs change ends
            self.swap()

    def swap(self) -> None:
        self.striker, self.other = self.other, self.striker

    def overs(self) -> str:  # 2.3 is 2 overs and 3 balls, never a decimal
        return f"{self.balls // 6}.{self.balls % 6}"


card = Card(["aditi", "bilal", "chen"])
for number, over in enumerate(LOG, start=1):
    if number > 1:  # each over is bowled from the other end
        card.swap()
    for token in over.split():
        card.deliver(token)
    score = f"{card.runs}/{card.wickets} in {card.overs()} overs"
    print(f"over {number}: {score}, {card.striker} on strike")
rate, wrong = card.runs * 6 / card.balls, card.runs / float(card.overs())
print(f"rate {rate:.2f} from {card.balls} balls, not {wrong:.2f}")

In Python: rstrip with a string of digits splits a token such as nb4 into its kind and its runs, and the two batters change ends in one tuple assignment.

Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py

JavaScript Node 24 · main.mjs

const LOG = [". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"]; // the overs, as the umpire called them

class Card {
  runs = 0;
  wickets = 0;
  balls = 0;

  constructor([striker, other, ...nextIn]) {
    Object.assign(this, { striker, other, nextIn });
  }

  deliver(token) {
    const kind = token.replace(/\d+$/, "");
    const runs = Number(token.slice(kind.length) || 0);
    if (kind === "wd" || kind === "nb")
      this.runs += 1; // a penalty run, and not a ball of the over
    else this.balls += 1;
    this.runs += runs;
    if (kind === "W") {
      this.wickets += 1;
      this.striker = this.nextIn.shift(); // the new batter takes strike
    }
    if (runs % 2 === 1) this.swap(); // odd runs change ends
  }

  swap() {
    [this.striker, this.other] = [this.other, this.striker];
  }

  overs() {
    return `${Math.floor(this.balls / 6)}.${this.balls % 6}`; // 2 overs and 3 balls, not a decimal
  }
}

const card = new Card(["aditi", "bilal", "chen"]);
LOG.forEach((over, i) => {
  if (i > 0) card.swap(); // each over is bowled from the other end
  for (const token of over.split(" ")) card.deliver(token);
  const score = `${card.runs}/${card.wickets} in ${card.overs()} overs`;
  console.log(`over ${i + 1}: ${score}, ${card.striker} on strike`);
});
const rate = (card.runs * 6) / card.balls;
const wrong = card.runs / Number(card.overs());
console.log(`rate ${rate.toFixed(2)} from ${card.balls} balls, not ${wrong.toFixed(2)}`);

In JavaScript: A regular expression strips a token's trailing digits, and destructuring in the constructor keeps the first two batters and the rest, the batters still to come.

Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs

Java 25 LTS · Main.java

import java.util.ArrayDeque;
import java.util.List;
import java.util.Locale;

public class Main {
  // The overs, as the umpire called them.
  static final List<String> LOG = List.of(". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1");

  static class Card {
    String striker;
    String other;
    final ArrayDeque<String> nextIn;
    int runs;
    int wickets;
    int balls;

    Card(String striker, String other, List<String> nextIn) {
      this.striker = striker;
      this.other = other;
      this.nextIn = new ArrayDeque<>(nextIn);
    }

    void deliver(String token) {
      String kind = token.replaceAll("\\d+$", "");
      int scored = kind.equals(token) ? 0 : Integer.parseInt(token.substring(kind.length()));
      if (kind.equals("wd") || kind.equals("nb")) { // a penalty run, and not a ball of the over
        runs++;
      } else {
        balls++;
      }
      runs += scored;
      if (kind.equals("W")) {
        wickets++;
        striker = nextIn.poll(); // the new batter takes strike
      }
      if (scored % 2 == 1) { // odd runs change ends
        swap();
      }
    }

    void swap() {
      String s = striker;
      striker = other;
      other = s;
    }

    String overs() {
      return balls / 6 + "." + balls % 6; // 2 overs and 3 balls, never a decimal
    }
  }

  public static void main(String[] args) {
    Locale.setDefault(Locale.ROOT);
    Card card = new Card("aditi", "bilal", List.of("chen"));
    for (int i = 0; i < LOG.size(); i++) {
      if (i > 0) { // each over is bowled from the other end
        card.swap();
      }
      for (String token : LOG.get(i).split(" ")) {
        card.deliver(token);
      }
      String score = card.runs + "/" + card.wickets + " in " + card.overs() + " overs";
      System.out.printf("over %d: %s, %s on strike%n", i + 1, score, card.striker);
    }
    double rate = card.runs * 6.0 / card.balls;
    double wrong = card.runs / Double.parseDouble(card.overs());
    System.out.printf("rate %.2f from %d balls, not %.2f%n", rate, card.balls, wrong);
  }
}

In Java: printf follows the default locale, so main sets Locale.ROOT first, and Double.parseDouble reads 2.3 the same way in every locale.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java

C++23 · main.cpp

#include <array>
#include <deque>
#include <print>
#include <sstream>
#include <string>
#include <utility>

// The overs, as the umpire called them.
constexpr std::array<const char *, 3> innings{". 1 wd 4 nb4 2 1 .",
                                              "W 1 6 . 2 1", "4 1 1"};

struct Card {
  std::string striker, other;
  std::deque<std::string> nextIn;
  int runs = 0, wickets = 0, balls = 0;

  void deliver(const std::string &token) {
    const auto at = token.find_first_of("0123456789");
    const std::string kind = token.substr(0, at);
    const int scored =
        at == std::string::npos ? 0 : std::stoi(token.substr(at));
    if (kind == "wd" || kind == "nb") {
      ++runs; // a penalty run, and not a ball of the over
    } else {
      ++balls;
    }
    runs += scored;
    if (kind == "W") {
      ++wickets;
      striker = nextIn.front(); // the new batter takes strike
      nextIn.pop_front();
    }
    if (scored % 2 == 1) { // odd runs change ends
      std::swap(striker, other);
    }
  }

  std::string overs() const { // 2 overs and 3 balls, never a decimal
    return std::to_string(balls / 6) + "." + std::to_string(balls % 6);
  }
};

int main() {
  Card card{"aditi", "bilal", {"chen"}};
  for (std::size_t i = 0; i < innings.size(); ++i) {
    if (i > 0) { // each over is bowled from the other end
      std::swap(card.striker, card.other);
    }
    std::istringstream tokens(innings[i]);
    for (std::string token; tokens >> token;) {
      card.deliver(token);
    }
    std::println("over {}: {}/{} in {} overs, {} on strike", i + 1, card.runs,
                 card.wickets, card.overs(), card.striker);
  }
  const double rate = card.runs * 6.0 / card.balls;
  const double wrong = card.runs / std::stod(card.overs());
  std::println("rate {:.2f} from {} balls, not {:.2f}", rate, card.balls,
               wrong);
}

In C++: std::println formats 12.00 without a locale, and the log is named innings, because log would clash with the math function of that name.

Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main

C# 14 · .NET 10 · app.cs

using System.Globalization;

// The overs, as the umpire called them.
string[] log = [". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"];
var card = new Card("aditi", "bilal", ["chen"]);
for (int i = 0; i < log.Length; i++)
{
    if (i > 0)
    {
        card.Swap(); // each over is bowled from the other end
    }
    foreach (string token in log[i].Split(' '))
    {
        card.Deliver(token);
    }
    string score = $"{card.Runs}/{card.Wickets} in {card.Overs()} overs";
    Console.WriteLine($"over {i + 1}: {score}, {card.Striker} on strike");
}
double rate = card.Runs * 6.0 / card.Balls;
double wrong = card.Runs / double.Parse(card.Overs(), CultureInfo.InvariantCulture);
Console.WriteLine(FormattableString.Invariant(
    $"rate {rate:F2} from {card.Balls} balls, not {wrong:F2}"));

class Card(string striker, string other, IEnumerable<string> nextIn)
{
    readonly Queue<string> next = new(nextIn);
    string other = other;
    public string Striker { get; private set; } = striker;
    public int Runs { get; private set; }
    public int Wickets { get; private set; }
    public int Balls { get; private set; }

    public void Deliver(string token)
    {
        string kind = token.TrimEnd("0123456789".ToCharArray());
        string digits = token[kind.Length..];
        int runs = digits == "" ? 0 : int.Parse(digits, CultureInfo.InvariantCulture);
        if (kind is "wd" or "nb")
        {
            Runs++; // a penalty run, and not a ball of the over
        }
        else
        {
            Balls++;
        }
        Runs += runs;
        if (kind == "W")
        {
            Wickets++;
            Striker = next.Dequeue(); // the new batter takes strike
        }
        if (runs % 2 == 1)
        {
            Swap(); // odd runs change ends
        }
    }

    public void Swap() => (Striker, other) = (other, Striker);

    public string Overs() => $"{Balls / 6}.{Balls % 6}"; // 2 overs and 3 balls, never a decimal
}

In C#: double.Parse and the interpolated text use the invariant culture, so 2.3 is never read as 23 or 12.00 printed as 12,00 where the decimal sign is a comma.

Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs

C23 · main.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// The overs, as the umpire called them.
static const char *const LOG[] = {". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"};

typedef struct {
  const char *striker, *other, *next_in;
  int runs, wickets, balls;
} Card;

static void swap_ends(Card *card) {
  const char *s = card->striker;
  card->striker = card->other;
  card->other = s;
}

static void deliver(Card *card, const char *token) {
  size_t len = strcspn(token, "0123456789"); // the letters before the runs
  int runs = atoi(token + len);
  if (len == 2 &&
      (strncmp(token, "wd", 2) == 0 || strncmp(token, "nb", 2) == 0)) {
    card->runs++; // a penalty run, and not a ball of the over
  } else {
    card->balls++;
  }
  card->runs += runs;
  if (strcmp(token, "W") == 0) {
    card->wickets++;
    card->striker = card->next_in; // the new batter takes strike
  }
  if (runs % 2 == 1) { // odd runs change ends
    swap_ends(card);
  }
}

int main(void) {
  Card card = {.striker = "aditi", .other = "bilal", .next_in = "chen"};
  for (size_t i = 0; i < sizeof LOG / sizeof LOG[0]; i++) {
    if (i > 0) { // each over is bowled from the other end
      swap_ends(&card);
    }
    char over[32];
    snprintf(over, sizeof over, "%s", LOG[i]);
    for (char *token = strtok(over, " "); token; token = strtok(NULL, " ")) {
      deliver(&card, token);
    }
    // 2.3 is 2 overs and 3 balls, never a decimal
    printf("over %zu: %d/%d in %d.%d overs, %s on strike\n", i + 1, card.runs,
           card.wickets, card.balls / 6, card.balls % 6, card.striker);
  }
  double rate = card.runs * 6.0 / card.balls;
  double wrong = card.runs / (card.balls / 6 + card.balls % 6 / 10.0);
  printf("rate %.2f from %d balls, not %.2f\n", rate, card.balls, wrong);
  return 0;
}

In C: strtok walks a copy of each over, and the wrong rate divides by the overs plus the balls over ten instead of parsing text; printf runs in the C locale.

Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main

PHP 8.5 · main.php

<?php

declare(strict_types=1);

const LOG = ['. 1 wd 4 nb4 2 1 .', 'W 1 6 . 2 1', '4 1 1']; // the overs, as the umpire called them

final class Card
{
    public int $runs = 0;
    public int $wickets = 0;
    public int $balls = 0;

    /** @param list<string> $nextIn */
    public function __construct(
        public string $striker,
        private string $other,
        private array $nextIn,
    ) {}

    public function deliver(string $token): void
    {
        $kind = rtrim($token, '0123456789');
        $runs = (int) substr($token, strlen($kind));
        if ($kind === 'wd' || $kind === 'nb') {
            $this->runs++; // a penalty run, and not a ball of the over
        } else {
            $this->balls++;
        }
        $this->runs += $runs;
        if ($kind === 'W') {
            $this->wickets++;
            $this->striker = array_shift($this->nextIn); // the new batter takes strike
        }
        if ($runs % 2 === 1) { // odd runs change ends
            $this->swap();
        }
    }

    public function swap(): void
    {
        [$this->striker, $this->other] = [$this->other, $this->striker];
    }

    public function overs(): string // 2 overs and 3 balls, never a decimal
    {
        return intdiv($this->balls, 6) . '.' . $this->balls % 6;
    }
}

$card = new Card('aditi', 'bilal', ['chen']);
foreach (LOG as $i => $over) {
    if ($i > 0) {
        $card->swap(); // each over is bowled from the other end
    }
    foreach (explode(' ', $over) as $token) {
        $card->deliver($token);
    }
    $score = "{$card->runs}/{$card->wickets} in {$card->overs()} overs";
    echo 'over ', $i + 1, ": $score, {$card->striker} on strike\n";
}
$rate = $card->runs * 6 / $card->balls;
$wrong = $card->runs / (float) $card->overs();
printf("rate %.2F from %d balls, not %.2F\n", $rate, $card->balls, $wrong);

In PHP: %.2F, with a capital F, formats without the locale, where %.2f would follow it, and rtrim with a list of digits splits a token into its kind and its runs.

Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php

Rust 1.99 · main.rs

const LOG: [&str; 3] = [". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"]; // the overs, as called

struct Card {
    striker: &'static str,
    other: &'static str,
    next_in: Vec<&'static str>,
    runs: u32,
    wickets: u32,
    balls: u32,
}

impl Card {
    fn deliver(&mut self, token: &str) {
        let kind = token.trim_end_matches(|c: char| c.is_ascii_digit());
        let runs: u32 = token[kind.len()..].parse().unwrap_or(0);
        if kind == "wd" || kind == "nb" {
            self.runs += 1; // a penalty run, and not a ball of the over
        } else {
            self.balls += 1;
        }
        self.runs += runs;
        if kind == "W" {
            self.wickets += 1;
            self.striker = self.next_in.remove(0); // the new batter takes strike
        }
        if runs % 2 == 1 {
            self.swap(); // odd runs change ends
        }
    }

    fn swap(&mut self) {
        std::mem::swap(&mut self.striker, &mut self.other);
    }

    fn overs(&self) -> String {
        format!("{}.{}", self.balls / 6, self.balls % 6) // 2 overs and 3 balls, not a decimal
    }
}

fn main() {
    let (striker, other, next_in) = ("aditi", "bilal", vec!["chen"]);
    let mut card = Card {
        striker,
        other,
        next_in,
        runs: 0,
        wickets: 0,
        balls: 0,
    };
    for (i, over) in LOG.iter().enumerate() {
        if i > 0 {
            card.swap(); // each over is bowled from the other end
        }
        over.split(' ').for_each(|token| card.deliver(token));
        let score = format!("{}/{} in {} overs", card.runs, card.wickets, card.overs());
        println!("over {}: {score}, {} on strike", i + 1, card.striker);
    }
    let rate = f64::from(card.runs) * 6.0 / f64::from(card.balls);
    let wrong = f64::from(card.runs) / card.overs().parse::<f64>().unwrap_or(1.0);
    println!("rate {rate:.2} from {} balls, not {wrong:.2}", card.balls);
}

In Rust: trim_end_matches with a closure splits the token, the runs are a u32, which cannot be negative, and f64::from converts them without a lossy cast.

Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main

TypeScript 6.0 · main.ts

const LOG = [". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"]; // the overs, as the umpire called them

class Card {
  runs = 0;
  wickets = 0;
  balls = 0;
  striker: string;
  other: string;
  readonly nextIn: string[];

  constructor([striker, other, ...nextIn]: string[]) {
    this.striker = striker;
    this.other = other;
    this.nextIn = nextIn;
  }

  deliver(token: string): void {
    const kind = token.replace(/\d+$/, "");
    const runs = Number(token.slice(kind.length) || 0);
    if (kind === "wd" || kind === "nb")
      this.runs += 1; // a penalty run, and not a ball of the over
    else this.balls += 1;
    this.runs += runs;
    if (kind === "W") {
      this.wickets += 1;
      this.striker = this.nextIn.shift() ?? "nobody"; // the new batter takes strike
    }
    if (runs % 2 === 1) this.swap(); // odd runs change ends
  }

  swap(): void {
    [this.striker, this.other] = [this.other, this.striker];
  }

  overs(): string {
    return `${Math.floor(this.balls / 6)}.${this.balls % 6}`; // 2 overs and 3 balls, not a decimal
  }
}

const card = new Card(["aditi", "bilal", "chen"]);
LOG.forEach((over, i) => {
  if (i > 0) card.swap(); // each over is bowled from the other end
  for (const token of over.split(" ")) card.deliver(token);
  const score = `${card.runs}/${card.wickets} in ${card.overs()} overs`;
  console.log(`over ${i + 1}: ${score}, ${card.striker} on strike`);
});
const rate = (card.runs * 6) / card.balls;
const wrong = card.runs / Number(card.overs());
console.log(`rate ${rate.toFixed(2)} from ${card.balls} balls, not ${wrong.toFixed(2)}`);

In TypeScript: shift() returns undefined once no batter is left, so ?? names one, and the compiler would refuse a striker of type string | undefined.

Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts

Go 1.27 · main.go

package main

import (
	"fmt"
	"strconv"
	"strings"
)

var innings = []string{". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"} // the overs, as called

type Card struct {
	striker, other       string
	nextIn               []string
	runs, wickets, balls int
}

func (c *Card) Deliver(token string) {
	kind := strings.TrimRight(token, "0123456789")
	runs, _ := strconv.Atoi(token[len(kind):])
	if kind == "wd" || kind == "nb" {
		c.runs++ // a penalty run, and not a ball of the over
	} else {
		c.balls++
	}
	c.runs += runs
	if kind == "W" {
		c.wickets++
		c.striker, c.nextIn = c.nextIn[0], c.nextIn[1:] // the new batter takes strike
	}
	if runs%2 == 1 { // odd runs change ends
		c.Swap()
	}
}

func (c *Card) Swap() { c.striker, c.other = c.other, c.striker }

func (c *Card) Overs() string { // 2 overs and 3 balls, never a decimal
	return fmt.Sprintf("%d.%d", c.balls/6, c.balls%6)
}

func main() {
	card := &Card{striker: "aditi", other: "bilal", nextIn: []string{"chen"}}
	for i, over := range innings {
		if i > 0 { // each over is bowled from the other end
			card.Swap()
		}
		for _, token := range strings.Fields(over) {
			card.Deliver(token)
		}
		score := fmt.Sprintf("%d/%d in %s overs", card.runs, card.wickets, card.Overs())
		fmt.Printf("over %d: %s, %s on strike\n", i+1, score, card.striker)
	}
	rate := float64(card.runs) * 6 / float64(card.balls)
	overs, _ := strconv.ParseFloat(card.Overs(), 64)
	fmt.Printf("rate %.2f from %d balls, not %.2f\n", rate, card.balls, float64(card.runs)/overs)
}

In Go: strings.TrimRight cuts the trailing digits, and Atoi of an empty string fails, so the ignored error leaves the runs of a token such as wd at 0.

Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go

Swift 6.4 · main.swift

let log = [". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1"]  // the overs, as the umpire called them

/// Two decimals, without Foundation's formatting.
func twoDecimals(_ x: Double) -> String {
  let cents = Int((x * 100).rounded())
  return "\(cents / 100)." + (cents % 100 < 10 ? "0" : "") + "\(cents % 100)"
}

struct Card {
  var striker: String, other: String
  var nextIn: [String]
  var runs = 0, wickets = 0, balls = 0

  mutating func deliver(_ token: Substring) {
    let kind = String(token.prefix { !$0.isNumber })
    let runs = Int(token.dropFirst(kind.count)) ?? 0
    if kind == "wd" || kind == "nb" {
      self.runs += 1  // a penalty run, and not a ball of the over
    } else {
      balls += 1
    }
    self.runs += runs
    if kind == "W" {
      wickets += 1
      striker = nextIn.removeFirst()  // the new batter takes strike
    }
    if runs % 2 == 1 {  // odd runs change ends
      swapEnds()
    }
  }

  mutating func swapEnds() { (striker, other) = (other, striker) }

  var overs: String { "\(balls / 6).\(balls % 6)" }  // 2 overs and 3 balls, never a decimal
}

var card = Card(striker: "aditi", other: "bilal", nextIn: ["chen"])
for (i, over) in log.enumerated() {
  if i > 0 {  // each over is bowled from the other end
    card.swapEnds()
  }
  for token in over.split(separator: " ") {
    card.deliver(token)
  }
  let score = "\(card.runs)/\(card.wickets) in \(card.overs) overs"
  print("over \(i + 1): \(score), \(card.striker) on strike")
}
let rate = Double(card.runs) * 6 / Double(card.balls)
let wrong = Double(card.runs) / (Double(card.overs) ?? 1)
print("rate \(twoDecimals(rate)) from \(card.balls) balls, not \(twoDecimals(wrong))")

In Swift: There is no String(format:) without Foundation, so twoDecimals rounds to cents and pads. The batters change ends with a tuple assignment, because swap on two properties of the global card is two overlapping accesses to card.

Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main

Ruby 4.0 · main.rb

# frozen_string_literal: true

LOG = ['. 1 wd 4 nb4 2 1 .', 'W 1 6 . 2 1', '4 1 1'].freeze # the overs, as the umpire called them

# The scorecard, folded from the deliveries one at a time.
class Card
  attr_reader :runs, :wickets, :balls, :striker

  def initialize(striker, other, next_in)
    @striker = striker
    @other = other
    @next_in = next_in
    @runs = @wickets = @balls = 0
  end

  def deliver(token)
    kind = token.sub(/\d+\z/, '')
    runs = token.delete_prefix(kind).to_i
    if %w[wd nb].include?(kind) # a penalty run, and not a ball of the over
      @runs += 1
    else
      @balls += 1
    end
    @runs += runs
    new_batter if kind == 'W'
    swap if runs.odd? # odd runs change ends
  end

  def swap
    @striker, @other = @other, @striker
  end

  # 2.3 is 2 overs and 3 balls, never a decimal.
  def overs = "#{@balls / 6}.#{@balls % 6}"

  private

  def new_batter
    @wickets += 1
    @striker = @next_in.shift # the new batter takes strike
  end
end

card = Card.new('aditi', 'bilal', ['chen'])
LOG.each_with_index do |over, i|
  card.swap if i.positive? # each over is bowled from the other end
  over.split.each { |token| card.deliver(token) }
  score = "#{card.runs}/#{card.wickets} in #{card.overs} overs"
  puts "over #{i + 1}: #{score}, #{card.striker} on strike"
end
rate = card.runs * 6.0 / card.balls
wrong = card.runs / Float(card.overs)
puts format('rate %<rate>.2f from %<balls>d balls, not %<wrong>.2f',
            rate:, balls: card.balls, wrong:)

In Ruby: delete_prefix and to_i give 0 runs for a token without digits, and Kernel#format with named references prints the two decimals.

Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb

Kotlin 2.4 · Main.kt

import kotlin.math.roundToInt

val log = listOf(". 1 wd 4 nb4 2 1 .", "W 1 6 . 2 1", "4 1 1") // the overs, as called

/** Two decimals: String.format exists only on the JVM. */
fun twoDecimals(x: Double): String {
    val cents = (x * 100).roundToInt()
    return "${cents / 100}." + (cents % 100).toString().padStart(2, '0')
}

class Card(
    var striker: String,
    private var other: String,
    private val nextIn: ArrayDeque<String>,
) {
    var runs = 0
    var wickets = 0
    var balls = 0

    fun deliver(token: String) {
        val kind = token.trimEnd { it.isDigit() }
        val scored = token.removePrefix(kind).toIntOrNull() ?: 0
        if (kind == "wd" || kind == "nb") runs++ else balls++ // a penalty run, not a ball
        runs += scored
        if (kind == "W") {
            wickets++
            striker = nextIn.removeFirst() // the new batter takes strike
        }
        if (scored % 2 == 1) swap() // odd runs change ends
    }

    fun swap() {
        striker = other.also { other = striker }
    }

    fun overs() = "${balls / 6}.${balls % 6}" // 2 overs and 3 balls, never a decimal
}

fun main() {
    val card = Card("aditi", "bilal", ArrayDeque(listOf("chen")))
    log.forEachIndexed { i, over ->
        if (i > 0) card.swap() // each over is bowled from the other end
        over.split(" ").forEach(card::deliver)
        val score = "${card.runs}/${card.wickets} in ${card.overs()} overs"
        println("over ${i + 1}: $score, ${card.striker} on strike")
    }
    val rate = card.runs * 6.0 / card.balls
    val wrong = card.runs / card.overs().toDouble()
    println("rate ${twoDecimals(rate)} from ${card.balls} balls, not ${twoDecimals(wrong)}")
}

In Kotlin: String.format exists only on the JVM, so twoDecimals rounds to cents and pads with padStart, and also swaps the two batters in one expression.

Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar

Dart 3.13 · main.dart

const log = [
  '. 1 wd 4 nb4 2 1 .',
  'W 1 6 . 2 1',
  '4 1 1',
]; // the overs, as called

class Card {
  Card(this.striker, this._other, this._nextIn);

  String striker;
  String _other;
  final List<String> _nextIn;
  var runs = 0, wickets = 0, balls = 0;

  void deliver(String token) {
    final kind = token.replaceAll(RegExp(r'\d+$'), '');
    final scored = int.tryParse(token.substring(kind.length)) ?? 0;
    if (kind == 'wd' || kind == 'nb') {
      runs++; // a penalty run, and not a ball of the over
    } else {
      balls++;
    }
    runs += scored;
    if (kind == 'W') {
      wickets++;
      striker = _nextIn.removeAt(0); // the new batter takes strike
    }
    if (scored.isOdd) swap(); // odd runs change ends
  }

  void swap() {
    final s = striker;
    striker = _other;
    _other = s;
  }

  String get overs =>
      '${balls ~/ 6}.${balls % 6}'; // 2 overs and 3 balls, not a decimal
}

void main() {
  final card = Card('aditi', 'bilal', ['chen']);
  for (final (i, over) in log.indexed) {
    if (i > 0) card.swap(); // each over is bowled from the other end
    over.split(' ').forEach(card.deliver);
    final score = '${card.runs}/${card.wickets} in ${card.overs} overs';
    print('over ${i + 1}: $score, ${card.striker} on strike');
  }
  final rate = card.runs * 6 / card.balls;
  final wrong = card.runs / double.parse(card.overs);
  print(
    'rate ${rate.toStringAsFixed(2)} from ${card.balls} balls, '
    'not ${wrong.toStringAsFixed(2)}',
  );
}

In Dart: toStringAsFixed(2) prints the two decimals, and a pattern assignment cannot write to fields, so swap uses a local variable.

Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart

What every language printed

over 1: 14/0 in 1.0 overs, aditi on strike
over 2: 24/1 in 2.0 overs, chen on strike
over 3: 30/1 in 2.3 overs, aditi on strike
rate 12.00 from 15 balls, not 13.04

Step through the first over and watch eight deliveries make six balls:

Interactive · step by step Over 1, folded one delivery at a time Step through the eight deliveries of the first over and watch the runs, the valid balls and the strike.
Over 1, folded one delivery at a time, step 8 of 8. Showing Over 1, as called, The card (14 runs, 6 balls: 1.0), Aditi (at the other end), Bilal (faces over 2). In focus: Bilal.one delivery a stepOver 1, as called. 1 wd 4 nb4 2 1 .The card14 runs, 6 balls: 1.0Aditiat the other endBilalfaces over 2

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. A dot ball. The first delivery scores nothing. It is a valid ball, so the card counts 1 ball, and Aditi keeps the strike.
  2. One run. Aditi takes a single. One run is odd, so the batters change ends and Bilal is on strike.
  3. A wide. The bowler sends a wide. It adds a penalty run but is not a ball of the over, so the balls stay at 2, and nobody runs.
  4. A four. Bilal hits a four. Four is even, so he keeps the strike; the card has 6 runs from 3 balls.
  5. A no ball hit for four. A no ball adds a penalty run and Bilal's 4. It is not a ball of the over either, so the balls stay at 3.
  6. Two, then one. Bilal runs two, which keeps the strike, then one, which is odd, so Aditi is on strike again with 14 runs from 5 balls.
  7. The sixth ball. A dot ball is the sixth valid ball. The over had eight deliveries, and the card says 1.0 overs, because it counts balls, not deliveries.
  8. Over. The umpire calls Over, and the next over is bowled from the other end, so Bilal faces its first ball.

After 2.3 overs, which is 15 balls, the side has 30: a rate of 12.00 an over, where dividing by 2.3 would give 13.04.

Key idea

Keep the deliveries as the umpire called them and fold the card from them, counting valid balls as integers: the display “2.3” is overs and balls, and every rate divides by balls.

Checkpoint

Check: Overs as balls, and the card as a fold

  1. Question 1 of 2 A scorecard shows 19.4 overs. How many valid balls is that?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 118 balls

    The figure after the point counts balls, not tenths: 19 × 6 + 4 = 118. That is why the model stores balls and writes "19.4" only for display.

  2. Question 2 of 2 Over 1 of the program ends with Aditi on strike. Who faces the first ball of over 2?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Bilal, because each over is bowled from the other end

    The program swaps the ends at the start of every over after the first, so Bilal faces, and the wicket on that ball brings Chen in to take the strike.

The first complete design, replayed from a ball-by-ball file

The run reads the Kestrels’ innings, one line per over as the umpire called it, with two subscribers attached, then prints the scorecard and checks it:

A T20 innings, replayed ball by ball

cricket/replay.py

"""A T20 innings replayed from its ball-by-ball file: two subscribers follow it live, then the scorecard is printed
and checked against the log it came from.
"""

from pathlib import Path

from cricket import Card, overs_and_balls, read_log

order, overs = read_log(Path("innings.txt").read_text())
card = Card(order)
moments = {}  # valid balls -> (runs, wickets) after the delivery that made them


def milestones(c, u):
    """A subscriber: wickets and fifties, as a live text service would push them."""
    if u.kind != "ball":
        return
    if u.ball.wicket:
        print(f"  over {u.over.number}: wicket, {c.fall[-1]}")
    for b in c.batters.values():
        if b.runs >= 50 and b.runs - u.ball.bat < 50 and u.ball.bat:
            print(f"  over {u.over.number}: fifty for {b.name}, from {b.balls} balls")


def every_five_overs(c, u):
    """A second subscriber: a score line at the end of every fifth over."""
    if u.kind == "ball":
        moments[c.valid] = (c.total, c.wickets)
    elif u.over.number % 5 == 0:
        print(f"  after {u.over.number} overs: {c.total}/{c.wickets} (version {u.version})")


card.subscribers += [milestones, every_five_overs]
print("Live:")
card.play(overs)

print(f"Kestrels {card.total}/{card.wickets} in {card.overs_text()} overs, run rate {card.total * 6 / card.valid:.2f}")
print(f"{'':28} {'R':>3} {'B':>3} {'4s':>3} {'6s':>3}")
for b in card.batters.values():
    print(f"{b.name:7} {b.how:20} {b.runs:>3} {b.balls:>3} {b.fours:>3} {b.sixes:>3}")
e = card.extras
print(f"Extras {sum(e.values())} (b {e['b']}, lb {e['lb']}, w {e['w']}, nb {e['nb']})")
print("Fall of wickets: " + ", ".join(card.fall))
print(f"{'Bowling':8} {'O':>3} {'M':>2} {'R':>3} {'W':>2}")
for w in card.bowling.values():
    print(f"{w.name:8} {overs_and_balls(w.overs, w.balls):>3} {w.maidens:>2} {w.runs:>3} {w.wickets:>2}")

runs, wkts = moments[118]
print(f"After 19.4 overs, which is 118 balls: {runs}/{wkts}, run rate {runs * 6 / 118:.2f}; dividing by 19.4 would give {runs / 19.4:.2f}")
batting_sum = sum(b.runs for b in card.batters.values()) + sum(e.values())
debited = sum(w.runs for w in card.bowling.values()) + e["b"] + e["lb"]
print(f"Check: batters' runs plus extras = {batting_sum}; bowlers' runs plus byes and leg byes = {debited}; total {card.total}")
print(f"Check: valid balls {card.valid} = 6 x {len(overs)} overs; warnings: {card.warnings or 'none'}")

Output

Live:
  over 2: wicket, 16-1 (Bilal, 1.3)
  after 5 overs: 52/1 (version 31)
  over 6: wicket, 55-2 (Chen, 5.4)
  over 8: fifty for Aditi, from 28 balls
  after 10 overs: 74/2 (version 62)
  over 11: wicket, 77-3 (Dara, 10.5)
  over 13: wicket, 93-4 (Aditi, 12.6)
  over 15: wicket, 101-5 (Farid, 14.3)
  after 15 overs: 108/5 (version 93)
  over 18: wicket, 147-6 (Esha, 17.6)
  after 20 overs: 176/6 (version 124)
Kestrels 176/6 in 20 overs, run rate 8.80
                               R   B  4s  6s
Aditi   run out (Umar)        65  48  10   1
Bilal   c Wen b Owen           5   3   1   0
Chen    b Quinn               10  11   0   1
Dara    st Tara b Sara         5  13   0   0
Esha    c Yusuf b Owen        34  19   3   2
Farid   lbw b Rohan            3   6   0   0
Gita    not out               31  16   1   3
Hari    not out               11   5   0   1
Extras 12 (b 4, lb 3, w 4, nb 1)
Fall of wickets: 16-1 (Bilal, 1.3), 55-2 (Chen, 5.4), 77-3 (Dara, 10.5), 93-4 (Aditi, 12.6), 101-5 (Farid, 14.3), 147-6 (Esha, 17.6)
Bowling    O  M   R  W
Nadia      4  0  45  0
Owen       4  0  46  2
Priya      4  0  43  0
Quinn      3  1   8  1
Rohan      3  0  18  1
Sara       2  0   9  1
After 19.4 overs, which is 118 balls: 173/6, run rate 8.80; dividing by 19.4 would give 8.92
Check: batters' runs plus extras = 176; bowlers' runs plus byes and leg byes = 176; total 176
Check: valid balls 120 = 6 x 20 overs; warnings: none

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 replay.py

cricket/cricket.py

"""A live cricket scorecard: deliveries are events, and the scorecard is derived from them, ball by ball.

The log holds what the scorers saw: each over as the umpire called it, with its bowler and its deliveries. A Card
folds the log one delivery at a time, following the Laws of Cricket for extras, strike and dismissals, and tells
its subscribers after every delivery. A correction replaces one delivery in the log, and the card is folded again.
"""

from dataclasses import dataclass, field, replace

CREDITED = {"bowled", "caught", "lbw", "stumped", "hit wicket"}  # dismissals credited to the bowler
STRIKER_OUT = CREDITED  # the not-out batter returns to their original end (Law 18.11.1)


class ScoringError(Exception):
    """A log that breaks a Law or a playing condition."""


@dataclass(frozen=True)
class Ball:
    """One delivery as the scorers record it."""

    call: str = ""  # "", "wide" or "no ball": each adds a one-run penalty (Laws 21.14, 22.6)
    bat: int = 0  # runs credited to the striker
    byes: int = 0
    leg_byes: int = 0
    wide_runs: int = 0  # runs completed from a wide, scored as wides (Law 22.7)
    boundary: bool = False  # the runs are a boundary allowance: the batters return to their ends (Law 18.11.2.1)
    wicket: str = ""
    fielder: str = ""
    out: str = "striker"  # for a run out: "striker" or "non-striker", once the completed runs have been run

    @property
    def valid(self) -> bool:  # wides and no balls are not balls of the over (Laws 21.16, 22.8)
        return self.call == ""

    @property
    def runs(self) -> int:
        return self.bat + self.byes + self.leg_byes + self.wide_runs + (1 if self.call else 0)

    @property
    def debited(self) -> int:  # runs against the bowler: off the bat, wides and no ball extras (Law 18.10.3)
        return self.bat + self.wide_runs + (1 if self.call else 0)


@dataclass(frozen=True)
class Over:
    number: int
    bowler: str
    balls: tuple[Ball, ...]


@dataclass(frozen=True)
class Update:
    """What subscribers are told: a delivery, or the end of an over, and the card's version (deliveries so far)."""

    kind: str  # "ball" or "over"
    over: Over
    ball: Ball | None
    version: int


TOKENS = {".": Ball()}
for n in (1, 2, 3):
    TOKENS[str(n)] = Ball(bat=n)
    TOKENS[f"b{n}"] = Ball(byes=n)
    TOKENS[f"lb{n}"] = Ball(leg_byes=n)
    TOKENS[f"wd{n}"] = Ball(call="wide", wide_runs=n)
    TOKENS[f"nb{n}"] = Ball(call="no ball", bat=n)
    TOKENS[f"nbb{n}"] = Ball(call="no ball", byes=n)  # byes from a no ball stay byes (Law 18.10.2)
for n in (4, 6):
    TOKENS[str(n)] = Ball(bat=n, boundary=True)
    TOKENS[f"nb{n}"] = Ball(call="no ball", bat=n, boundary=True)
TOKENS.update({"wd": Ball(call="wide"), "nb": Ball(call="no ball"), "b4": Ball(byes=4, boundary=True), "lb4": Ball(leg_byes=4, boundary=True)})


def ball(token: str) -> Ball:
    """'4', 'wd', 'nb2', 'lb1', 'W:caught:Wen', 'W:runout:Umar:non-striker:1' and so on."""
    if not token.startswith("W:"):
        return TOKENS[token]
    how, *rest = token[2:].split(":")
    how = how.replace("hitwicket", "hit wicket").replace("runout", "run out")
    if how == "run out":
        fielder, who, runs = rest
        return Ball(bat=int(runs), wicket=how, fielder=fielder, out=who)
    return Ball(wicket=how, fielder=rest[0] if rest else "")


def read_log(text: str) -> tuple[list[str], list[Over]]:
    """The batting order and the overs of an innings file: 'batting: A, B, …' then '<over> <bowler>: <deliveries>'."""
    order, overs = [], []
    for line in text.splitlines():
        line = line.split("#")[0].strip()
        if line.startswith("batting:"):
            order = [n.strip() for n in line[len("batting:") :].split(",")]
        elif line:
            head, deliveries = line.split(":", 1)
            number, bowler = head.split()
            overs.append(Over(int(number), bowler, tuple(ball(t) for t in deliveries.split())))
    return order, overs


def overs_text(balls: int) -> str:
    """Valid balls as a scorecard writes them: 118 balls is '19.4', 19 overs and 4 balls."""
    return f"{balls // 6}.{balls % 6}" if balls % 6 else str(balls // 6)


def overs_and_balls(overs: int, balls: int) -> str:
    """Completed overs, as the umpire called them, and the valid balls of the over in progress."""
    return f"{overs}.{balls}" if balls else str(overs)


@dataclass
class Batter:
    name: str
    runs: int = 0
    balls: int = 0
    fours: int = 0
    sixes: int = 0
    how: str = "not out"


@dataclass
class Bowling:
    name: str
    overs: int = 0  # overs completed, as called
    balls: int = 0  # valid balls of the over in progress
    maidens: int = 0
    runs: int = 0
    wickets: int = 0


@dataclass
class Card:
    order: list[str]
    max_overs: int = 20
    per_bowler: int = 4  # ICC T20 playing condition 13.9.1
    subscribers: list = field(default_factory=list)

    def __post_init__(self) -> None:
        self.batters = {n: Batter(n) for n in self.order[:2]}
        self.striker, self.other = self.order[0], self.order[1]
        self.total = self.wickets = self.valid = 0
        self.overs = self.balls = 0  # overs called, and valid balls of the over in progress
        self.extras = {"b": 0, "lb": 0, "w": 0, "nb": 0}
        self.bowling: dict[str, Bowling] = {}
        self.fall: list[str] = []
        self.warnings: list[str] = []
        self.last_bowler = ""
        self.overs_bowled = {}
        self.version = 0

    def tell(self, update: Update) -> None:
        for notify in self.subscribers:
            notify(self, update)

    def play(self, overs: list[Over]) -> "Card":
        for over in overs:
            self.start(over)
            for b in over.balls:
                self.apply(over, b)
            self.finish(over)
        return self

    def start(self, over: Over) -> None:
        if over.bowler == self.last_bowler:
            raise ScoringError(f"over {over.number}: {over.bowler} bowled the previous over too (Law 17.6)")
        if self.overs_bowled.get(over.bowler, 0) == self.per_bowler:
            raise ScoringError(f"over {over.number}: {over.bowler} has bowled {self.per_bowler} overs already")
        self.bowling.setdefault(over.bowler, Bowling(over.bowler))
        self.over_debited = 0

    def apply(self, over: Over, b: Ball) -> None:
        s, bw = self.batters[self.striker], self.bowling[over.bowler]
        self.total += b.runs
        self.extras["b"] += b.byes
        self.extras["lb"] += b.leg_byes
        self.extras["w"] += b.wide_runs + (b.call == "wide")
        self.extras["nb"] += b.call == "no ball"
        s.runs += b.bat
        s.balls += b.call != "wide"  # a wide is out of the striker's reach (Law 22.1), so it is not a ball faced
        s.fours += b.bat == 4 and b.boundary
        s.sixes += b.bat == 6 and b.boundary
        bw.runs += b.debited
        self.over_debited += b.debited
        if b.valid:
            self.valid += 1
            self.balls += 1
            bw.balls += 1
        ran = 0 if b.boundary else b.bat + b.byes + b.leg_byes + b.wide_runs
        if b.wicket in STRIKER_OUT:
            ran = 0  # no runs but penalties when a batter is out this way (Law 18.8)
        if ran % 2:
            self.striker, self.other = self.other, self.striker
        if b.wicket:
            self.wicket(over, b)
        self.version += 1
        self.tell(Update("ball", over, b, self.version))

    def wicket(self, over: Over, b: Ball) -> None:
        bowler = over.bowler
        out = self.other if b.wicket == "run out" and b.out == "non-striker" else self.striker
        self.batters[out].how = {
            "bowled": f"b {bowler}",
            "caught": f"c {b.fielder} b {bowler}",
            "lbw": f"lbw b {bowler}",
            "stumped": f"st {b.fielder} b {bowler}",
            "hit wicket": f"hit wicket b {bowler}",
            "run out": f"run out ({b.fielder})",
        }[b.wicket]
        self.wickets += 1
        self.bowling[bowler].wickets += b.wicket in CREDITED
        self.fall.append(f"{self.total}-{self.wickets} ({out}, {self.overs_text()})")
        if self.wickets < len(self.order) - 1:
            new = self.order[self.wickets + 1]
            self.batters[new] = Batter(new)
            if out == self.striker:
                self.striker = new
            else:
                self.other = new

    def finish(self, over: Over) -> None:
        legal = sum(b.valid for b in over.balls)
        if legal != 6 and self.wickets < len(self.order) - 1 and over.number < self.max_overs:
            self.warnings.append(f"over {over.number} has {legal} valid balls; the over as the umpire counted it stands (Law 17.5.1)")
        bw = self.bowling[over.bowler]
        if legal == 6 and self.over_debited == 0:
            bw.maidens += 1
        bw.overs, bw.balls = bw.overs + 1, 0
        self.overs, self.balls = self.overs + 1, 0
        self.overs_bowled[over.bowler] = self.overs_bowled.get(over.bowler, 0) + 1
        self.last_bowler = over.bowler
        self.striker, self.other = self.other, self.striker  # the next over is bowled from the other end
        self.tell(Update("over", over, None, self.version))

    def overs_text(self) -> str:
        return overs_and_balls(self.overs, self.balls)


def corrected(overs: list[Over], number: int, index: int, fixed: Ball) -> list[Over]:
    """A new log with one delivery replaced; folding it into a fresh Card is the correction."""
    return [replace(o, balls=o.balls[:index] + (fixed,) + o.balls[index + 1 :]) if o.number == number else o for o in overs]

cricket/innings.txt

# The Kestrels' innings against the Herons, a 20-over match: one line per over as the umpire called it, with its
# bowler and its deliveries in order. . is no run; 1 2 3 runs off the bat; 4 6 boundaries; wd a wide (wd1: and a run);
# nb4 a no ball hit for four; b4 four byes; lb1 a leg bye; W:how[:fielder] a wicket; W:runout:fielder:who:runs a run
# out of the striker or non-striker after the runs completed.
batting: Aditi, Bilal, Chen, Dara, Esha, Farid, Gita, Hari, Ines, Jai, Kavi
1 Nadia: 4 1 1 4 . 1
2 Owen: 1 4 W:caught:Wen . . .
3 Nadia: 4 4 . 4 1 6
4 Owen: 4 wd . 6 lb1 1 .
5 Priya: 1 . 2 1 . .
6 Quinn: 1 2 . W:bowled . .
7 Priya: . . 4 . . nb4 2
8 Rohan: . . . 1 2 1
9 Quinn: . . . . . .
10 Rohan: . . . 1 1 2
11 Sara: 1 1 . 1 W:stumped:Tara .
12 Priya: . 4 b4 . . 2
13 Sara: . . 1 4 . W:runout:Umar:non-striker:1
14 Quinn: 1 . 2 1 . 1
15 Rohan: 1 wd1 . W:lbw 1 2 4
16 Owen: 4 1 4 . 1 1
17 Nadia: . . . 6 1 1
18 Owen: 2 4 lb2 6 6 W:caught:Yusuf
19 Nadia: . 1 1 wd 1 2 1
20 Priya: 6 1 6 6 2 1

What to read in the output:

  • Live One subscriber pushes wickets and Aditi’s fifty, the other a score every five overs with the card’s version: 124 deliveries, because the 120 valid balls came with four wides or no balls.
  • The card Aditi’s run out is in nobody’s bowling figures, and the over 9 maiden belongs to Quinn. The no ball hit for four gave Aditi 4 and Priya 5; the four byes in over 12 are in extras only.
  • The checks The batters’ runs plus extras and the bowlers’ runs plus byes and leg byes both make 176.
Cricket Scorer & Scorecard Maker Score two overs of a real game ball by ball, then check your extras against the rules above.

Checkpoint

Check: The full model on a T20 innings

  1. Question 1 of 2 The Kestrels' innings had 120 valid balls, and the card's version at the end was 124. How many deliveries were wides or no balls?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 4 deliveries

    The version counts the deliveries folded, 124, and 120 of them were valid balls, so 4 were wides or no balls.

  2. Question 2 of 2 In over 12 the ball goes past the bat and the keeper for four byes. Who is charged with the 4 runs?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nobody; they are extras, and byes are not debited to the bowler

    Byes are credited to the batting side as extras, which is why the run's check adds byes and leg byes to the bowlers' runs to reach the total of 176.

Corrections and miscounted overs, replayed against a counting design Pro: opens with a Pro, Premium or Ultimate pass

A scorecard that works out the end of each over by counting is right only while every delivery is entered right. This part plays a wrong entry and an umpire’s miscount through both designs, with every figure that moves, and shows why the observed over survives both.

In this part:

  • A wrong entry and a miscount, reproduced in both designs
  • Why the over number comes from the umpire, as an invariant

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Low-level design: parking lot (the model)

Extras, strike and maidens at their edges, as a table of tests Pro: opens with a Pro, Premium or Ultimate pass

Most scoring bugs live in a dozen single deliveries, so this part writes those deliveries as a table and runs it. It then works through the arithmetic of a chase, where the same integers decide a result.

In this part:

  • Fifteen single deliveries, run as a table
  • Chasing a target, worked in balls

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Publishing to subscribers, follow-ups, a rubric and a self-mock Pro: opens with a Pro, Premium or Ultimate pass

Updates reach subscribers late, twice or out of order, and corrections arrive after the fact. This part measures a board that adds runs against one that keeps the newest version, sends a correction after the innings, and keeps a slow phone current.

In this part:

  • Duplicates, delays and a late correction, measured
  • A slow subscriber, and why the newest is enough
  • Follow-up questions and where each change lands
  • Patterns used and rejected
  • What each level is expected to cover, as a rubric
  • A 45-minute self-mock

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; nothing renews unless you choose to. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise: free hits

The exercise adds free hits: the delivery after a no ball, on which the striker can be out in only a few ways. Its sample tests run in your browser:

Exercise · Medium · Python

Free hits after a no ball, and which dismissals stand

Many limited-overs matches add a rule to the Laws of Cricket: the delivery after a no ball is a free hit, on which the striker cannot be out in most ways. Add it to the scorer as a small class, following the ICC's T20 playing condition 21.19 and Laws 21.17 and 22.9.

FreeHits().deliver(call, dismissal) is told about each delivery in order. call is "" for a valid ball, "wide" or "no ball"; dismissal is "" or one of "bowled", "caught", "lbw", "stumped", "hit wicket", "run out", "hit the ball twice" and "obstructing the field". It returns a pair: whether this delivery was a free hit, and whether the dismissal stands (None when there was none).

  • F1 The delivery after a no ball is a free hit.
  • F2 When a free hit is itself a wide or a no ball, the next delivery is a free hit too. A wide that is not a free hit makes nothing a free hit.
  • F3 On a free hit, a dismissal stands only if it could stand off a no ball: run out, hit the ball twice or obstructing the field. This holds even when the free hit is a wide.
  • F4 Otherwise, off a wide only hit wicket, stumped, run out and obstructing the field stand; off a no ball only run out, hit the ball twice and obstructing the field; off a valid ball every dismissal stands.
  • F5 Any other call or dismissal raises ValueError.

The sample tests name the rule each one checks.

Starter code · free_hit.py

"""Free hits after a no ball, and which dismissals stand: the exercise of the live cricket scorecard lesson."""


class FreeHits:
    def __init__(self) -> None:
        pass

    def deliver(self, call: str = "", dismissal: str = "") -> tuple[bool, bool | None]:
        """F1 to F5: (was this delivery a free hit, does the dismissal stand or None when there is none)."""
        raise NotImplementedError
The sample tests · test_free_hit.py
from free_hit import FreeHits


def sequence(*deliveries):
    f = FreeHits()
    return [f.deliver(*d) for d in deliveries]


def test_f1_the_ball_after_a_no_ball():
    """only the next delivery is a free hit"""
    assert sequence(("no ball",), ("",), ("",)) == [(False, None), (True, None), (False, None)]


def test_f2_a_wide_on_a_free_hit_carries_it():
    """the free hit moves to the next delivery"""
    assert sequence(("no ball",), ("wide",), ("",), ("",)) == [(False, None), (True, None), (True, None), (False, None)]


def test_f2_a_no_ball_on_a_free_hit_carries_it():
    """a no ball on a free hit gives another free hit"""
    assert sequence(("no ball",), ("no ball",), ("",), ("",)) == [(False, None), (True, None), (True, None), (False, None)]


def test_f2_a_wide_alone_makes_no_free_hit():
    """a wide that is not a free hit is just a wide"""
    assert sequence(("wide",), ("",)) == [(False, None), (False, None)]


def test_f3_dismissals_on_a_free_hit():
    """only the no-ball ways of getting out stand on a free hit"""
    stands = {}
    for how in ("bowled", "caught", "lbw", "stumped", "hit wicket", "run out", "hit the ball twice", "obstructing the field"):
        stands[how] = sequence(("no ball",), ("", how))[1]
    assert stands == {
        "bowled": (True, False),
        "caught": (True, False),
        "lbw": (True, False),
        "stumped": (True, False),
        "hit wicket": (True, False),
        "run out": (True, True),
        "hit the ball twice": (True, True),
        "obstructing the field": (True, True),
    }


def test_f3_a_free_hit_wide_is_still_a_free_hit():
    """stumped stands off a wide, but not off a free hit that is a wide"""
    assert sequence(("no ball",), ("wide", "stumped"))[1] == (True, False)


def test_f4_dismissals_off_a_wide():
    """stumped and hit wicket stand off a wide, caught does not"""
    assert sequence(("wide", "stumped"), ("wide", "hit wicket"), ("wide", "caught")) == [(False, True), (False, True), (False, False)]


def test_f4_dismissals_off_a_no_ball_and_a_valid_ball():
    """bowled does not stand off a no ball; run out does; lbw stands off a valid ball"""
    assert sequence(("no ball", "bowled")) == [(False, False)]
    assert sequence(("no ball", "run out")) == [(False, True)]
    assert sequence(("", "lbw")) == [(False, True)]


def test_f1_a_run_out_on_a_free_hit_ends_it():
    """a valid free hit is used up, whatever happens on it"""
    assert sequence(("no ball",), ("", "run out"), ("",)) == [(False, None), (True, True), (False, None)]


def test_f5_unknown_calls_and_dismissals():
    """anything else is refused"""
    f = FreeHits()
    for args in [("bouncer",), ("", "retired")]:
        try:
            f.deliver(*args)
        except ValueError:
            continue
        raise AssertionError(f"deliver{args} was accepted")
A hint

Keep one flag: whether the next delivery is a free hit. Each call of deliver() reads the flag to answer, then sets it for the next delivery: true after a no ball, and true again after a wide or a no ball that was itself a free hit. Write the dismissals that can stand as three sets, one for a no ball, one for a wide and one for a valid ball, and let a free hit use the no-ball set.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Test the scoring rules above, one idea at a time, beginning with the one you have practised least:

Practice that adapts to you

Practise this lesson

8 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 Which runs is the bowler charged with?

    Choose every answer that is right.

    Show the answer: Question 1 of 8

    Answer:

    • No-ball extras
    • Wides
    • Runs off the bat

    The card keeps two sums that must agree with the total: the batters' runs plus all extras, and the bowlers' runs plus byes and leg byes.

  2. Question 2 of 8 In over 7, Priya bowls a no ball and the striker hits it for four. What does the card record?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: 4 runs to the batter and 1 no-ball extra, 5 against Priya, a ball faced but not a ball of the over

    Laws 21.14 and 21.15 set the penalty and the striker's runs, Law 18.10.3 the bowler's debit and Law 21.16 that a no ball is not one of the over.

  3. Question 3 of 8 Which class owns the rules R2 to R6, from the derived scorecard to the updates?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: Card, which folds the log delivery by delivery

    Ball and Over hold what the scorers saw, and Card turns them into the scorecard, so a new rule changes one class.

  4. Question 4 of 8 Why does the model record the end of each over when the umpire calls it, instead of counting six valid balls?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: The Laws make the umpire's count final, even when it is wrong

    An observed over keeps the card right through a wrong entry or a miscount, which a counted one cannot.

  5. Question 5 of 8 In the program's tokens, which deliveries are not balls of the over?

    Choose every answer that is right.

    Show the answer: Question 5 of 8

    Answer:

    • nb4, a no ball hit for four
    • wd, a wide

    Over 1 had eight deliveries, a wide and a no ball among them, and the card counted six balls: 1.0 overs.

  6. Question 6 of 8 The Kestrels have 173 runs after 19.4 overs. What is their run rate per over, to two decimal places?

    Type a number.

    Show the answer: Question 6 of 8

    Answer: 8.8 runs per over (anything from 8.79 to 8.81 counts)

    Divide by overs of 6 balls: 173 × 6 ÷ 118 = 8.80. Dividing by 19.4, as if it were a decimal, gives 8.92 and overstates the rate.

  7. Question 7 of 8 How many deliveries had the card folded at the end of the Kestrels' 20 overs?

    Type a number.

    Show the answer: Question 7 of 8

    Answer: 124 deliveries

    The live subscriber printed "after 20 overs, 176/6 (version 124)": the version is the number of deliveries folded so far.

  8. Question 8 of 8 Which of the Kestrels' dismissals does the card credit to the bowler?

    Choose every answer that is right.

    Show the answer: Question 8 of 8

    Answer:

    • Chen, bowled by Quinn
    • Dara, stumped by Tara off Sara
    • Bilal, caught by Wen off Owen

    Caught, bowled, stumped, lbw and hit wicket count for the bowler; a run out does not, so Aditi's wicket is in nobody's bowling figures.

Interview questions

  1. Fresher to mid · Warm-up

    Why does a cricket scorecard design store valid balls rather than overs written as 19.4?

    Because "19.4" is not a decimal: it is two numbers, 19 overs and 4 balls, which is 118 balls. Stored as a float, it breaks arithmetic at once. Adding a ball to 19.5 gives 19.6, which is not a score; the run rate of 173 runs after 19.4 overs is 173 × 6 ÷ 118 = 8.80, while 173 ÷ 19.4 gives 8.92; and a bowler's economy, a required rate and the balls left in a chase all go wrong the same way. So the model counts valid balls as an integer, takes the number of overs from the umpire's calls, and writes "19.4" only when it shows the score. Wides and no balls are not valid balls, so they never move the count.

  2. Mid to senior

    Twenty minutes after a wicket alert went to every subscriber, the scorers find that the ball on which Esha was bowled was a no ball, so she is not out. Your design keeps the scorecard as running totals. What breaks, and how do you redesign it so that a correction is routine?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to staff

    In a club match the umpire lets an over run to seven valid balls, and the batter is out off the seventh. Your scoring app ends an over after counting six valid balls. What does it show, what should it show, and what do you change?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Your live score app shows 183/4 while the ground shows 176/4. Updates reach phones through a message queue that delivers at least once. Find the cause, and fix it without making the publisher slower.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Keep a log of deliveries as the umpire called them, and derive the whole scorecard from it.
  • Store valid balls as integers; “19.4” is 118 balls, and every rate divides by balls.
  • Put extras, strike and dismissals in one class that follows the Laws, so a new rule changes one place.
  • Let scoreboards and alerts hear about each delivery as subscribers, through updates that carry the card’s version.

Terms in this lesson

extras
Runs credited to the batting side but to no batter: no-ball and wide penalties and the runs made from them, byes and leg byes.
valid ball
A delivery that counts as one of the six balls of an over; a wide or a no ball adds a penalty run and is bowled again, so it is not a valid ball.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.