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.
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.
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
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:
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.
- Scorers, drawn as a person, record each delivery or the umpire's call of Over in the log.
- The log, drawn as a database, holds the overs as called, each with its deliveries. It is the only thing that is ever written.
- An arrow labelled "fold, ball by ball" leads from the log to the Card, which is folded from the log.
- An arrow labelled "Update (revision, version)" leads from the Card to the subscribers: boards, alerts and text commentary.
- 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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 120 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 157 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 5 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 49 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 735 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 197 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 17 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Step through the first over and watch eight deliveries make six balls:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A dot ball. The first delivery scores nothing. It is a valid ball, so the card counts 1 ball, and Aditi keeps the strike.
- One run. Aditi takes a single. One run is odd, so the batters change ends and Bilal is on strike.
- 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.
- A four. Bilal hits a four. Four is even, so he keeps the strike; the card has 6 runs from 3 balls.
- 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.
- 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.
- 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.
- 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
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:
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 Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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.
Checkpoint
Check: The full model on a T20 innings
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
Interview questions
-
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.
-
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?
-
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?
-
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.
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
- The Laws of Cricket (2017 Code, 4th Edition, 2026) (Marylebone Cricket Club (MCC))
- Law 17: The over (Marylebone Cricket Club (MCC))
- Law 18: Scoring runs (Marylebone Cricket Club (MCC))
- Law 21: No ball (Marylebone Cricket Club (MCC))
- Law 22: Wide ball (Marylebone Cricket Club (MCC))
- Law 23: Bye and leg bye (Marylebone Cricket Club (MCC))
- ICC Men's T20 World Cup 2026 Playing Conditions (International Cricket Council (ICC))
- dataclasses: frozen instances and replace() (Python Software Foundation)
- collections: deque objects with a maximum length (Python Software Foundation)
- The Swift Programming Language: memory safety (conflicting access to properties) (Swift.org)
- sprintf (PHP manual): the f and F conversions (The PHP Group)
- Class Locale (Java SE 25): Locale.ROOT (Oracle)
- CultureInfo.InvariantCulture Property (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress