A live scoreboard looks like a counter. It is not: the same tap means something different at 40-40 in tennis, at 40-40 in padel with a golden point, at 6-6 in a set, and in pickleball when the receivers win a rally. This post walks through how we modelled padel, tennis and pickleball scoring as a small, pure state machine in TypeScript, and why we never store the score, only the points.
The code comes from the scorekeeper in our domain package (a framework-free TypeScript package inside a pnpm monorepo). Snippets are trimmed from the real file.
The rule that removes a class of bugs: store points, replay the score
The first decision is what to persist. We persist the log of points, one entry per rally, and derive everything else:
export interface ScorePoint { readonly at: number; // epoch ms, from the caller's clock readonly side: "a" | "b"; } export function replayScore( format: ScoreFormat, firstServer: ScoreSide, points: readonly ScorePoint[] ): ScoreReplay { const work = fresh(format, firstServer); for (const point of points.slice(0, SCOREKEEPER_MAX_POINTS)) { if (work.winner) break; step(work, format, point.side); // ...stats are tallied off this same loop } return { snapshot: snapshotOf(work, format), stats: /* ... */ }; } A stored "current score" next to the log would be two sources of truth, and they disagree the first time an undo is buggy. A log replayed from zero cannot disagree with itself. That gives you three things for free:
- Undo is dropping the last element of the array. There is no inverse operation to write, so there is nothing to get wrong.
- Stats are honest. Break points, longest run and serve-point percentages are counted in the same loop that draws the board.
- The function is pure. The clock arrives inside each point (
at), so replay is deterministic and trivial to test.
A cap (SCOREKEEPER_MAX_POINTS = 1500) bounds the cost of replaying; a five-set tennis marathon is on the order of 450 points, so the cap is a safety limit, not a constraint.
The format is data
Everything that varies between sports and clubs is a field on one object:
export type DeuceRule = "advantage" | "golden" | "star"; export interface ScoreFormat { readonly bestOf: 1 | 3 | 5; readonly decidingSet: "set" | "super_tiebreak"; readonly deuce: DeuceRule; readonly gamesPerSet: 6 | 4; readonly pickleballScoring: "rally" | "sideout"; readonly pointsToWin: 11 | 15 | 21; readonly sport: "padel" | "pickleball" | "tennis"; readonly teamSize?: 1 | 2; } Defaults follow the sport: padel starts with the golden point, tennis with advantage, pickleball with side-out scoring played to 11. The state machine itself never asks "is this padel?" about deuce, only "what does format.deuce say?".
The state: one small mutable record per replay
Inside a replay we keep a mutable work record (copied with clone where we need to look ahead). The essential fields are the games, the raw points in the current game, a deuce counter, a phase and who serves:
type ScorePhase = "game" | "super_tiebreak" | "tiebreak"; interface Work { games: [number, number]; points: [number, number]; // raw counts, not 15/30/40 deuces: number; phase: ScorePhase; server: ScoreSide; winner: ScoreSide | null; // ...completed sets, change-of-ends flag, tie-break first server } Points are stored as raw counts. "15, 30, 40" is a display concern, handled at the edge by a lookup table; inside the machine, 40-40 is simply 3-3.
Deuce, advantage, golden point
A game ends when a side has at least four points and leads by two, unless the next point is a deciding point. Deciding points are the only thing the deuce rule changes:
function isDecidingPoint(work: Work, format: ScoreFormat): DeuceRule | null { if (work.phase !== "game" || format.sport === "pickleball") return null; const [a, b] = work.points; if (a !== b || a < 3) return null; if (format.deuce === "golden") return "golden"; if (format.deuce === "star" && work.deuces >= STAR_POINT_DEUCE) return "star"; return null; } function racketGamePoint(work: Work, format: ScoreFormat, side: ScoreSide) { const w = slot(side); const l = w === 0 ? 1 : 0; const deciding = isDecidingPoint(work, format) !== null; work.points[w] += 1; if (deciding || (work.points[w] >= 4 && work.points[w] - work.points[l] >= 2)) { winGame(work, format, side); return; } if (work.points[0] === work.points[1] && work.points[0] >= 3) work.deuces += 1; } Reading that:
- Advantage (
deuce: "advantage"):isDecidingPointreturns null, so the "lead by two" clause rules and the game can go on indefinitely. - Golden point (
"golden"): at 3-3 the very next point decides, whoever wins it. - Star point (
"star"): the machine counts how many times the game came back to deuce, and the third deuce is the deciding point.STAR_POINT_DEUCEis 3, so the first deuce is at 3-3 and the third is a few points later at 5-5 in raw counts. Our source describes it as the 2026 federation star point.
Note that tennis "no-ad" is the same machine as padel's golden point, which is why the tennis scoring options offer only advantage or golden.
Tie-breaks and the serve
When a game is won, winGame flips the server, checks whether the set is over (games >= gamesPerSet and a two-game lead), and at 6-6 (or 4-4 in short sets) switches phase to "tiebreak". Tie-break points are plain counts to 7, again win by two:
function tiebreakPoint(work: Work, format: ScoreFormat, side: ScoreSide) { const w = slot(side); const l = w === 0 ? 1 : 0; work.points[w] += 1; const target = work.phase === "tiebreak" ? TIEBREAK_TO : SUPER_TIEBREAK_TO; // 7 or 10 const played = work.points[0] + work.points[1]; if (work.points[w] >= target && work.points[w] - work.points[l] >= 2) { // the receivers of the tie-break's first point serve the next set work.server = otherSide(work.tiebreakFirstServer); /* ...close the set... */ return; } // first server serves one point, then the serve changes every two if (played % 2 === 1) work.server = otherSide(work.server); work.changeEnds = played % TIEBREAK_ENDS_EVERY === 0; // every 6 points } The serve rotation is the usual trap: the first server takes one point, then the serve changes every two points. Written as "switch whenever the number of points played is odd", it is one line and needs no extra state. The same branch handles a deciding super tie-break to 10 (decidingSet: "super_tiebreak"), because only the target differs.
Pickleball: side-out is a different machine
Pickleball does not fit the game/set model, so step dispatches to its own function. The key difference is that in side-out scoring only the serving side can score:
if (format.pickleballScoring === "sideout" && side !== work.server) { // The receivers won the rally: no point, the serve moves on. if (work.serverNumber === 1) { work.serverNumber = 2; } else { work.server = side; work.serverNumber = hasServerNumber(format) ? 1 : null; } return; } work.points[w] += 1; In doubles each side gets two servers before a side-out, and the first rally of a game starts with server number 2 (the call "0-0-2"). The machine keeps serverNumber only when it applies (side-out doubles), and the call is built from it: serving score, receiving score, server number. In singles there is no second server, so a lost rally is a side-out straight away. With rally scoring the point always counts, and the winner of the rally takes the serve. A game ends at the target (11, 15 or 21) with a two-point lead.
Looking ahead without a second implementation
The UI wants to show "set point" or "break point" before it happens. Instead of duplicating the rules, we try the next point on a copy and compare:
function chanceFor(work: Work, format: ScoreFormat, side: ScoreSide) { const probe = clone(work); step(probe, format, side); if (probe.winner) return "match"; if (probe.completedSets.length > work.completedSets.length) return "set"; // a game won on the other side's serve is a break /* ... */ } Because the probe goes through the exact same step, the hint can never disagree with what actually happens when the point is tapped.
What we would do again
- Persist events, derive state. Undo and stats come for free.
- Make the format data. Deuce rule, set length and deciding-set rule are fields, not branches scattered through the code.
- Raw counts inside, tennis calls at the edge.
- Reuse the real step function for look-ahead.
If you want to see the result in action, there is a free live scoreboard for padel, tennis and pickleball at meetandplay.mx/herramientas/marcador. It is built on the code above.
