Match Formats — Flexible Multi-Set Scoring (Phase C)
Status: Active — Waves 1-3b SHIPPED (foundations, engine wiring, seed/read-path groundwork, format picker + per-set entry UI). Live-scoring's deliberate ship-now scope (below) remains the only deferred piece. Date: 2026-08-27 Authority: foundational-schema-decisions.md §3.5
0. What this is
TwoMore's scorecard has always recorded exactly one game-pair per match — the 세트 수 (set count) selector in the match-rules editor writes 1_set / 2_of_3 / 3_of_5 into match_rules.format, but nothing downstream ever read it (the "lying selector," killed in the §3.5(1) SHIP-NOW step). This spec is the real thing: a registry of named scoring formats, a pure engine that validates and completes multi-set matches against any of them, and the schema wave 2/3 build on.
Phase C ships in three waves. This document describes the whole design; waves 1-2 are built as of this writing. Wave 3 is HELD — see §5.
1. The TODS-style code grammar
The format registry (public.match_formats) labels each row with a compact, human+machine-readable code, styled after the ITF/CourtHive TODS matchUpFormat convention (SET3-S:6/TB7, etc.). The standalone tods-matchup-format-code npm package was unpublished 2026-05-07 (verdict correction in the decisions doc) and the full tods-competition-factory is the wrong tool for a from-scratch subset, so this codebase implements its own narrow grammar — covering exactly the 6 ratified formats, not the full TODS spec.
matchUpFormat := "SET" bestOf "-" setSpec finalOverride?
bestOf := "1" | "3" | "5"
setSpec := "S:" games "/TB" tbTarget
finalOverride := "-F:" ( "TB" tbTarget ; match-tiebreak-in-lieu
| "S:" games "/TB" tbTarget ) ; full final set, own breaker
games, tbTarget := positive integer, 2..30Examples:
| Code | Meaning |
|---|---|
SET1-S:6/TB7 | Single set to 6 games, 7-point breaker at 6-6. |
SET3-S:6/TB7 | Best of 3, every set identical (6 games, TB7). |
SET3-S:6/TB7-F:TB10 | Best of 3; sets 1-2 as above; the decider is skipped entirely — a single 10-point breaker instead. |
SET5-S:6/TB7-F:S:6/TB10 | Best of 5; sets 1-4 as above; the decider IS a real 6-game set, just with its own breaker raised to 10 points. |
Implemented in packages/app/src/domain/rules/scoring.rules.ts — parseMatchFormatCode(todsCode): MatchFormatSpec, pure and total over the grammar (throws MatchFormatCodeError on anything else, including out-of-range numbers).
Documented grammar limitations
The code is a compact display label, not a lossless serialization. The spec JSONB column is the engine's actual source of truth; the two are independent, hand-kept-in-sync representations (see §3). Three things the grammar cannot express, all only relevant to fast4_bo3:
- winBy is always assumed 2 when parsing (both the set margin and the breaker margin). Fast4's real breaker is sudden-death (winBy 1 at 4-4) — the hand-typed spec carries
winBy: 1;parseMatchFormatCodewould producewinBy: 2if asked to derive it fromSET3-S:4/TB5. - the breaker trigger is always assumed to sit at parity with
games(games=6 → triggers at 6-6). Fast4's real rule triggers one game early (3-3 for a 4-game set) — the hand-typed spec carriestriggerAt: 3; the parser would default to 4. - noAd is never derivable from the code — always parses to
false. Onlyfast4_bo3sets ittrue, directly in the hand-typed JSONB.
scoring.rules.test.ts asserts this divergence explicitly ('DOES NOT round-trip fast4_bo3 — documented grammar limitation'), rather than leaving it as a silent gap. Every other seeded format round-trips losslessly through parseMatchFormatCode.
2. MatchFormatSpec — the engine's source of truth
interface TiebreakSpec {
triggerAt: number; // games score (both sides) at which the breaker replaces further play
target: number; // points needed to win the breaker
winBy: number; // margin required once target is reached
}
interface SetSpec {
games: number;
winBy: number;
tiebreak: TiebreakSpec | null; // null = advantage set, no breaker
}
type FinalSetSpec =
| { kind: 'match_tiebreak'; target: number; winBy: number }
| { kind: 'final_set'; games: number; winBy: number; tiebreak: TiebreakSpec | null };
interface MatchFormatSpec {
todsCode: string;
bestOf: 1 | 3 | 5;
set: SetSpec; // governs every set EXCEPT a finalSet-overridden decider
finalSet: FinalSetSpec | null; // override for the LAST-POSSIBLE set (positional, not "however the match ends")
noAd: boolean; // documentation/display only, same precedent as MatchRulesSchema.deuce
}Defined in packages/app/src/domain/entities/match-format.entity.ts as a .strict() Zod schema. finalSet is "final" positionally — set number bestOf — independent of whether the match actually reaches that set (a 2-0 sweep in a bo3 never touches the override).
Deviations from the §3.5 sketch
The ratified sketch gives the base shape as { todsCode, bestOf, set:{games,winBy,tiebreak}, finalSet:{kind,target}, noAd } and explicitly invites refinement ("refine as the seeds require"). Two refinements were necessary:
1. tiebreak and the match-tiebreak finalSet both carry their own winBy, not just target. A breaker isn't complete just by reaching the target — "first to 7" still has to be won by 2. Omitting winBy would make isSetComplete unable to validate the margin at all.
2. finalSet is a discriminated union of TWO kinds, not the sketch's single literal 'match_tiebreak'. The sketch gives match_tiebreak_in_lieu_bo3 and grand_slam_bo5_10pt_final the identical -F:TB10-shaped code, differing only in bestOf. But these are two genuinely different real-world formats:
match_tiebreak— the ITF match-tiebreak-in-lieu-of-a-third-set convention (common in doubles/mixed leagues): the decider is skipped entirely. A single breaker totargetdecides the match. No games are played — recorded as a 0-0-games "set," real-world notation"6-4 4-6 [10-7]".final_set— the modern Grand Slam decider rule (unified across the majors in 2022): the decider IS a real set, still played to 6 games, just with its own breaker raised from 7 to 10 points at 6-6.
Collapsing both into one shape would make them indistinguishable to the engine — isSetComplete couldn't tell whether a completed decider should show real games or 0-0. match_formats's seed data reflects this: format_key = 'grand_slam_bo5_10pt_final' ships with tods_code = 'SET5-S:6/TB7-F:S:6/TB10' (a real games/breaker pair), not the sketch's -F:TB10. format_key = 'match_tiebreak_in_lieu_bo3' keeps the sketch's exact code — that format genuinely IS the bare-breaker shape.
A third, non-schema deviation: the registry table is named match_formats (plural), not match_format (the sketch's literal name). public.match_format already exists — it's the ENUM type backing matches.format ('singles' | 'doubles' | 'mixed_doubles', migrations 00001/00253). CREATE TABLE implicitly creates a composite row type in the same pg_type namespace as the schema's ENUM types, so CREATE TABLE public.match_format fails outright. The mirrored TS domain entity is ScoringFormat, not MatchFormat, for the identical reason at the TypeScript layer (MatchFormat is already taken — the singles/doubles/ mixed_doubles union in match.entity.ts).
3. The 6 ratified formats
format_key | tods_code | Real-world format |
|---|---|---|
korean_single_set | SET1-S:6/TB7 | Single set to 6, TB7 at 6-6. |
itf_tiebreak_bo3 | SET3-S:6/TB7 | Best of 3, every set identical. |
match_tiebreak_in_lieu_bo3 | SET3-S:6/TB7-F:TB10 | Best of 3; decider replaced by a 10-pt breaker. |
grand_slam_bo5_10pt_final | SET5-S:6/TB7-F:S:6/TB10 | Best of 5; decider is a real set with a 10-pt breaker at 6-6. |
pro_set_8 | SET1-S:8/TB7 | Single set to 8, TB7 at 8-8. |
fast4_bo3 | SET3-S:4/TB5 | Best of 3, sets to 4, breaker at 3-3 to 5 (sudden death), no-ad. |
Seeded in migration 00506_match_format_registry.sql. tods_code and spec are independent, hand-authored representations, kept in sync BY HAND — the migration's pgTAP coverage (match_format_registry.test.sql) asserts spec SHAPE (required keys present), not a live round-trip through the parser (which would fail for fast4_bo3 per the grammar limitations above). The same 6 objects are hand-mirrored a third time in scoring.rules.test.ts as engine-test fixtures — drift between any two of the three (migration seed, pgTAP shape assertion, engine test fixture) is the failure mode to watch for on any future edit to this registry.
4. Score-semantics invariants
These hold across every wave, not just wave 1:
matches.team1_score/team2_scorestay "games in the DISPLAYED set." Current set while live, final set once completed. Never overloaded to mean sets — this is the #1 rule from §3.5, restated because it's the easiest thing for a future editor to get wrong.match_setsrows hold the actual per-set truth — one row per set,team{1,2}_games+tiebreak_team{1,2}, plus (wave 1)is_match_tiebreakandset_kindfor the shape markers described in §2.matches.team1_sets_won/team2_sets_wonis the best-of-N RESULT, derived frommatch_setsviaisMatchComplete(wave 2 populates it server-side; wave 1 ships the columns only, always NULL).- Outcome for a multi-set match derives from
sets_won, not fromteam1_score/team2_score(which, per invariant 1, might just be the last set's games — misleading as a whole-match signal on its own).
5. Wave map
| Wave | Scope | Status |
|---|---|---|
| 1 — foundations | match_formats registry (table + RLS + 6 seed rows); matches.team1_sets_won/team2_sets_won/format_id; match_sets.is_match_tiebreak/set_kind; the ScoringFormat domain entity + read-only port/adapter/hook; the pure scoring.rules.ts engine (parseMatchFormatCode, isSetComplete, isMatchComplete, validateSetScores) with exhaustive unit coverage. No RPC changes. No UI. No seed-scenario changes. | SHIPPED (migration 00506) |
| 2 — engine wiring | Set-aware submit_match_score rewrite accepting a p_sets JSONB param + p_format_key/p_format_id; set-aware game_pct for ELO (UTR per-set weighting: set-TB = 1 game — already baked into the stored N+1/N games, no extra code needed; match-TB-in-lieu = 2 games credited to its winner), gated to matches that HAVE match_sets rows, with an explicit pgTAP invariant test that a single-set submit's elo delta is byte-identical to the pre-wave-2 formula. Populates team*_sets_won/format_id/is_match_tiebreak/set_kind server-side for the first time. DROP+CREATE both submit_match_score signatures (00502-incident law); apply_match_ratings keeps its exact signature (CREATE OR REPLACE is correct there). | SHIPPED (migration 00507) |
| 3a — read-path + seed groundwork | MatchScoreboard's multi-set read path (scoreboardCellsFromMatchSets, packages/app/src/presentation/components/sessions/match-scoreboard.tsx) — renders match_sets rows as per-set SetCell[], falling back to the existing single-cell shape for any legacy match; useSessionMatchSets/selectMatchSets (Protocol A bulk hook) wired into that read path; seed-scenario's generateMatchSets + the widened two-sided-tiebreak seed_score_match (migration 00508) so dev/QA data can exercise multi-set matches. Still no format picker, no entry UI — the 세트 수 selector stayed hidden through this wave. | SHIPPED (migration 00508, commit 585026b9) |
| 3b — format picker + per-set entry | Un-hides the 세트 수 selector in match-rules-editor.tsx (registry-backed PillNav, MatchRules.formatKey + coherent legacy format mirroring); per-set score entry in match-card.tsx (MultiSetScoreEntry, progressive rows via the pure nextEntryState engine in match-board-helpers.ts); registry format labels surfaced in round-rules-strip + create-session step5/6 review. Single-set matches (formatKey absent or resolving to bestOf: 1) are byte-for-byte unchanged. | SHIPPED (2026-08-27, owner sign-off: pre-launch, no staged rollout) |
⚠️ Process note: foundational-schema-decisions.md §6 item 3 lists ELO-recalibration sign-off as a blocker for wave 2, and item 4 gated un-hiding the
세트 수selector on a rollout-scope decision (all clubs vs. leagues-only). Wave 2 shipped (migration 00507) WITHOUT that sign-off, on explicit direction from the session that commissioned it — the ratings invariant (single-set matches are byte-identical, proven bymulti_set_submit.test.sql's invariance block) means no EXISTING match's rating changed. Wave 3b's own sign-off (owner, 2026-08-27): un-hide for ALL clubs, pre-launch, no staged/leagues-only rollout — the product hasn't shipped to real clubs yet, so there's no existing-club cohort to stage against.
Live-scoring's deliberate ship-now scope
Wave 3's live-scoring UI ships current-set games only for the pair shown mid-match — the same "games in the displayed set" model that already exists. Per-set live entry (watching every prior set update in real time) is explicitly deferred, not an oversight: the existing live-score infrastructure (update_live_match_score, lastEditedBy/lastEditedAt attribution) is single-set-shaped, and extending it to a live per-set feed is materially more work than the entry/scoreboard surface wave 3 actually needs (a final per-set recap after the match, not a move-by-move multi-set broadcast). Revisit if a league/tournament use case demands it.
Confirmed as-shipped in wave 3b: MultiSetScoreEntry (match-card.tsx / multi-set-score-entry.tsx) submits the WHOLE set-score payload once the match is decided (nextEntryState.matchComplete), via the existing submit_match_score RPC + p_sets — there is no per-set live broadcast, no new live-editing surface, and no change to update_live_match_score. Each set's games/tiebreak are entered progressively client-side (revealed row by row as the prior set completes, per nextEntryState), but nothing is persisted server-side until the whole match is submitted — exactly the "final per-set recap after the match" scope this section already committed to, not a move-by-move feed.
6. What wave 1 deliberately did NOT touch
- No RPC signature changed.
submit_match_score(migration 00147) is untouched — every match today still scores exactly one game-pair. - No UI. The
세트 수selector stays hidden (§3.5 step 4, already shipped before this wave). - No seed-scenario changes (
scripts/seed-scenarioanddev-panel/scenario-chains.jsonare wave 3's concern). match_formats.format_idand the four newmatch_sets/matchescolumns have zero writers — every existing and new match keeps scoring through the untouched single-set path until wave 2 lands.
7. What wave 2 deliberately did NOT touch
- No UI. The
세트 수selector stays hidden — no club can reach thep_setspath through the product surface yet (wave 3, gated on the rollout-scope sign-off above).match.supabase.ts'supdateScoreanduse-submit-score.ts'sSubmitScoreInput.sets/formatKeyexist end-to- end and are exercised by tests, but have zero producers in any screen. - No seed-scenario changes (
scripts/seed-scenarioanddev-panel/scenario-chains.jsonare still wave 3's concern). match-card.tsx/MatchScoreboard/ scorecard rows are untouched.MatchSetRepositoryPort.submitSets— the client-side direct-table-INSERT write method wave 1 shipped as "dev-tool territory, HELD until wave 3" — was DELETED, not kept. Ruling: it modeled an INCORRECT write path (a rawmatch_setsINSERT bypasses the entire format-validation +sets_won/format_idderivation + rating engine thatprivate.derive_match_setsnow owns exclusively); keeping it callable invited a future caller to reach for it instead of the RPC. The port only exposes reads now (findByMatch,findBySession); any genuinely-needed dev-only direct write should be its own clearly-labeled SECURITY DEFINER RPC, not a bare client INSERT.useMatchSets(matchId)(a single-match hook) is still absent — wave 2 shippeduseSessionMatchSets(sessionId)(the Protocol A bulk read, consumed nowhere yet either) + aselectMatchSetsper-match selector derived from its result.MatchSetRepositoryPort.findByMatchstays reserved for wave 3's single-match hook.