Building a Cricket Scoring App: Ball-by-Ball Events, Undo and Net Run Rate
Scoring a local cricket match on paper works until someone asks "how many did he get off how many balls?", or the umpire signals a no-ball after you've already written down the run. EasyScore is the scoring app I built for these games: ball-by-ball scoring, a full scorecard, tournaments with a points table, all saved on the phone without needing an internet connection at the ground.
This article is about the engine underneath it: how one design decision made undo trivial, how the laws of cricket turn into code, and how to calculate net run rate correctly, including the rule most homemade points tables get wrong.
The key decision: store events, not the score
The obvious way to build a scorer is to keep a scoreboard object (runs, wickets, overs, each batter's score) and update it every time a ball is bowled. The trouble starts with corrections. If the last ball was recorded as a four but was actually leg byes, you have to reverse every change it made: the batter's runs, the boundary count, the bowler's figures, the partnership, the strike. Every correction is a chance to corrupt the scorecard.
EasyScore never stores the score at all. An innings is just a list of events, in order:
start { striker, nonStriker, bowler }
ball { runs: 1 }
ball { runs: 4 }
ball { runs: 0, extra: 'wide' }
ball { runs: 0, wicket: { type: 'bowled', playerOut } }
newBatter { player }
...
The scorecard is computed by replaying those events from the start, every time. That gives three things almost for free:
- Undo is one line. Remove the last event and recompute. There is no reverse logic to write and nothing to get out of sync.
- The scorecard can't contradict itself. Team total, batter scores, bowler figures and fall of wickets all come from the same replay, so they always agree.
- Over history comes free. The ball-by-ball list is the data, so showing "1 4 Wd W . 2" for an over needs no extra storage.
The undo function also has to deal with innings boundaries. If the innings has just been closed, undo reopens it. If the second innings has no events yet, undo steps back into the first innings. Otherwise it drops the last event:
// Simplified from the app's actions.ts
export function undo(match: Match): Match {
const idx = match.innings.length - 1;
const current = match.innings[idx];
if (current.closed) return reopenInnings(match, idx);
if (current.events.length === 0) {
if (idx === 0) return match;
return reopenFirstInnings(match);
}
return withCurrentInnings(match, (events) => events.slice(0, -1));
}
Is replaying every ball slow? A 20-over innings is around 150 events, counting extras and new batters. Looping over a list that size is effectively instant, even on a budget phone, so for a scoring app this design costs nothing noticeable.
Turning the laws of cricket into code
The replay loop is where cricket's rules live. Most of them are simple on their own, but several interact, and those interactions are where homemade scorers go wrong.
Legal deliveries
An over has six legal balls. Wides and no-balls don't count towards the six, so the engine tracks legal balls separately from events:
export const isLegal = (ball: BallEvent) =>
ball.extra !== 'wide' && ball.extra !== 'noball';
The overs figure ("14.3") is derived from the legal-ball count: 87 legal balls is 14 overs and 3 balls.
Who gets credited with what
This is the most detailed part of the engine, because each type of extra is treated differently:
| Delivery | Ball faced by batter? | Runs to batter? | Runs against bowler? |
|---|---|---|---|
| Normal ball | Yes | Yes | Yes |
| Wide | No | No | Yes |
| No-ball | Yes | Yes (runs off the bat) | Yes |
| Bye / leg bye | Yes | No | No |
Note the no-ball: it doesn't count towards the over, but it does count as a ball faced by the batter, and a six hit off it belongs to the batter. Byes and leg byes count against neither the batter nor the bowler.
Local cricket adds a twist: in many gully and tennis-ball matches, wides and no-balls don't give a run at all, they're simply re-bowled. EasyScore makes the penalty runs a match setting (wideRuns and noBallRuns, 1 or 0) rather than hard-coding the international rule.
Strike rotation
Two rules decide who faces the next ball. The batters cross on an odd number of runs, and they swap ends at the end of every over. In code:
if (ev.runs % 2 === 1) swap();
// ...later, after the over's sixth legal ball:
if (legal && s.legalBalls % 6 === 0) { /* close the over */ swap(); }
Both rules apply on the same ball. A single off the last ball of an over swaps twice, so the same batter keeps the strike for the next over, which is exactly what happens on the field. For the cases the rules can't predict, such as batters who don't cross on a run-out, there is a manual "swap strike" event, which is recorded and undoable like everything else.
Maidens
A maiden is an over in which the bowler concedes no runs. Byes and leg byes aren't charged to the bowler, so they don't spoil a maiden, but wides and no-balls do. The engine sums the over's runs while skipping byes and leg byes, then checks for zero.
When is a team all out?
With eleven players, a team is all out after ten wickets, because a batter can't bat alone. Local matches are often six- or eight-a-side, so EasyScore derives the limit from the team size:
export const wicketLimit = (match: Match) =>
Math.max(1, match.config.playersPerSide - 1);
Net run rate, done properly
In a tournament, teams level on points are separated by net run rate (NRR). The definition sounds simple:
NRR = (total runs scored ÷ total overs faced)
− (total runs conceded ÷ total overs bowled)
Two details trip people up. First, overs must be converted to balls before dividing. "18.2 overs" is 18 overs and 2 balls (110 balls), not 18.2 overs, so EasyScore stores everything in balls and calculates the rate as runs × 6 ÷ balls.
Second, and more important: if a team is bowled out, the calculation uses its full quota of overs, not the overs it actually lasted. Without this rule, getting bowled out quickly would barely hurt your NRR. Here is how the engine applies it:
const balls = s.wickets >= wicketLimit(m)
? m.config.overs * 6 // all out: charged the full quota
: s.legalBalls;
A worked example
Team A plays two 20-over matches:
- Match 1: A scores 160/6 in 20 overs. B is bowled out for 140 in 18.2 overs. A wins.
- Match 2: A scores 150/8 in 20 overs. C chases it down, 151/3 in 17.4 overs. A loses.
A's run rate for: 310 runs in 240 balls = 7.750 runs per over.
A's run rate against: B was all out, so B's innings counts as the full 120 balls, not 110. C used 106 balls. That's 291 runs in 226 balls = 7.726 runs per over.
NRR = 7.750 − 7.726 = +0.024.
If you forget the all-out rule and use 110 balls for B, the "against" rate becomes 291 runs in 216 balls = 8.083, and A's NRR comes out at −0.333. That's a positive NRR turned negative by one missed rule, enough to knock a team out of the semi-finals in a close league.
The points table then sorts by points, then NRR, then wins, and finally alphabetically so the order is always stable.
Testing the rules
Because the engine is pure functions (events in, scorecard out, with no UI or storage), it's straightforward to test. EasyScore's cricket engine has tests written with Node's built-in node:test runner, covering strike rotation, over completion and maidens, all-out conditions, awards, and points-table ordering with NRR. A typical test builds a short list of events, computes the innings and asserts the result: after a single, the batters should have swapped. These tests are what make it safe to change the engine without breaking a scorecard someone is relying on halfway through a match.
Lessons that apply beyond cricket
- If users will make mistakes, store what happened, not the result. An append-only list of events makes corrections safe. The same idea is behind bank ledgers and Git.
- Make local rules configurable. Real-world rules vary, and an app that forces international rules on a Sunday tennis-ball match won't get used.
- Work in the smallest unit. Balls instead of overs, paise instead of rupees. Convert to display units only at the edges.
- Look for the rule behind the formula. The NRR formula fits on one line; the all-out rule is what makes it correct.
EasyScore is free, works offline, and also scores football, basketball, tennis, badminton, table tennis, volleyball, hockey and kabaddi. Give it a try at your next match.