Bracket as a deterministic match graph: a fixture engine that supports manual override
Brackets aren't a rendering problem. They're a state-and-event problem with a particularly visual presentation.
Most tournament management software treats brackets as views - render the matches in a tree, look pretty. The data model underneath is usually a flat list of matches with parent pointers, and adding a new format ("league + playoff with consolation bracket") means a code change to the rendering layer.
We made a different decision early. The bracket was the data, not the view. Every format generated a deterministic match graph that the rest of the platform - fixtures, results, payouts, leaderboards - operated on. The view came last.
This is the writeup on that data model and why it kept paying off.
The decision
A tournament's structure is a directed graph. Nodes are matches. Edges are "the winner of this match advances to that one." Round-robin tournaments are a complete graph. Knockouts are trees. Group + playoff is two phases (one per phase) with edges between them.
We modeled all of these as the same data structure:
create table tournaments ( id uuid primary key, name text not null, format text not null, -- 'single_elim' | 'double_elim' | 'round_robin' | 'group_playoff' config jsonb not null, -- format-specific knobs state text not null, -- 'draft' | 'fixtures_published' | 'in_progress' | 'completed' created_at timestamptz not null default now() ); create table matches ( id uuid primary key, tournament_id uuid not null references tournaments(id), match_code text not null, -- 'QF1', 'SF2', 'F', 'A1' etc - human-readable round int not null, -- 1 = first round, 2 = next, etc position int not null, -- ordering within a round team_a_id uuid, -- nullable until determined team_b_id uuid, -- nullable until determined team_a_source jsonb, -- { type: 'winner_of', match_code: 'QF1' } team_b_source jsonb, -- or { type: 'seed', seed: 1 } scheduled_at timestamptz, status text not null, -- 'pending' | 'scheduled' | 'live' | 'completed' | 'walkover' | 'forfeit' | 'disputed' result jsonb, -- format-specific score data unique (tournament_id, match_code) );
The crucial column is team_a_source. A match doesn't necessarily know its participants when it's generated. The final's team_a_source is { type: "winner_of", match_code: "SF1" }. When SF1 completes and a winner is recorded, a projection updates the final's team_a_id. The generation of the bracket and the resolution of who plays in each match are decoupled.
This decoupling is what made manual override sane.
Format generators
Each format is a deterministic generator from (seedList, config) → matches[]:
type Seed = { teamId: string; seedNumber: number }; function generateSingleElimination(seeds: Seed[]): MatchSpec[] { const power = nextPowerOfTwo(seeds.length); const padded = padWithByes(seeds, power); // top seeds get byes const totalRounds = Math.log2(power); const matches: MatchSpec[] = []; // Round 1: actual seeded pairings using bracket-style seeding // (1 vs 16, 8 vs 9, 5 vs 12, ...) const round1Pairs = bracketSeeding(padded); round1Pairs.forEach((pair, i) => { matches.push({ matchCode: `R1M${i + 1}`, round: 1, position: i, teamASource: pair[0].isBye ? null : { type: "seed", seed: pair[0].seedNumber }, teamBSource: pair[1].isBye ? null : { type: "seed", seed: pair[1].seedNumber }, }); }); // Subsequent rounds: each match takes the winners of two prior matches for (let round = 2; round <= totalRounds; round++) { const matchesThisRound = power / Math.pow(2, round); for (let i = 0; i < matchesThisRound; i++) { const sourceA = matches.find((m) => m.matchCode === `R${round - 1}M${i * 2 + 1}`); const sourceB = matches.find((m) => m.matchCode === `R${round - 1}M${i * 2 + 2}`); matches.push({ matchCode: `R${round}M${i + 1}`, round, position: i, teamASource: { type: "winner_of", matchCode: sourceA!.matchCode }, teamBSource: { type: "winner_of", matchCode: sourceB!.matchCode }, }); } } return matches; }
The function is deterministic - same seeds, same config, same matches. This determinism mattered: regenerating a bracket produced the same shape, which made overrides predictable.
Round-robin and group + playoff each had their own generator. Each produced a list of MatchSpec rows that the platform inserted in one transaction.
The override layer
Real-life tournaments require overrides. Every fixture engine that doesn't support them gets abandoned within a week. Examples we hit:
- "Team B can't make the Saturday slot, swap matches QF2 and QF3."
- "Team C dropped out, give Team D a walkover into the semis."
- "Re-seed: actually team B is stronger than team A."
- "We're playing best-of-3 in the final, not best-of-1 like the rest."
We made overrides first-class events:
create table match_overrides ( id uuid primary key, match_id uuid not null references matches(id), override_type text not null, -- 'reseed' | 'reschedule' | 'walkover' | 'format_change' | 'team_replace' payload jsonb not null, reason text not null, applied_by uuid not null, applied_at timestamptz not null default now() );
The original match's row stayed unchanged. The override row recorded the change. The current state of a match was a projection - original spec + overrides applied in order.
This is the same event-sourcing pattern as the EV rental state machine. The reason it kept showing up was that operators kept needing to ask "why does this match look like this?" The audit answer was always: original generation, plus N overrides, applied by these operators with these reasons.
Match state machine
Each match had its own state machine separate from the tournament. Eight states, eight transitions:
| From | To | Trigger |
|---|---|---|
| pending | scheduled | both teams resolved (originally seeded or sourced from completed matches) |
| scheduled | live | organizer marks the match in progress |
| scheduled | walkover | one team didn't show; opponent advances |
| scheduled | forfeit | a team withdrew before the match |
| live | completed | organizer enters a result |
| completed | disputed | a player or coach raises an objection within the dispute window |
| disputed | confirmed | organizer resolves the dispute, possibly amending the result |
| completed | confirmed | dispute window passes, result locks |
completed and confirmed are different states for a reason. completed means a result has been entered. confirmed means the result has stopped being challengeable and downstream events (advancement, payout) can proceed. Walkover and forfeit flow directly to confirmed; only a confirmed result triggers propagation to dependent matches.
Result propagation
When a match was confirmed, its winner had to propagate to dependent matches. We did this with a worker that ran on every match-confirmed event:
async function propagateResult(matchId: string) { const match = await db.matches.findById(matchId); if (!match || match.status !== "confirmed") return; const winnerId = pickWinner(match.result); // format-specific const loserId = match.teamAId === winnerId ? match.teamBId : match.teamAId; // Find dependent matches that source from this one const dependents = await db.matches.find({ tournamentId: match.tournamentId, teamASource: { type: "winner_of", matchCode: match.matchCode }, }); const dependentsLoser = await db.matches.find({ tournamentId: match.tournamentId, teamASource: { type: "loser_of", matchCode: match.matchCode }, // double-elim }); for (const dep of dependents) { if (dep.teamASource?.matchCode === match.matchCode) { dep.teamAId = winnerId; } if (dep.teamBSource?.matchCode === match.matchCode) { dep.teamBId = winnerId; } await db.matches.update(dep); // If both teams now resolved, transition to scheduled if (dep.teamAId && dep.teamBId && dep.status === "pending") { await transitionMatch(dep.id, "scheduled"); } } }
This single function handled tournament progression. Dispute resolution was the same code: when a disputed match's result changed, we reverted the dependent matches' team assignments, then re-propagated with the new winner. The audit trail captured each step.
Walkovers and forfeits: first-class, not edge cases
A common bracket-software bug: walkover handling is hacked in late. We made it primary.
A walkover is recorded with the same shape as a match result, just with a different status. The opponent advances; the platform treats it identically to a played match for propagation. The audit trail records the reason ("opponent did not arrive within 30 minutes of scheduled start").
Forfeits are similar but explicit - a team withdraws before the match. The opponent advances. If the forfeiting team had paid an entry fee, the prize-pool calculation accounted for it (the forfeited team's entry stayed in the pool; their notional prize, if any, redistributed).
These cases exist in every tournament. Most software treats them as exceptions; ours treated them as states. The result: organizers reported less anxiety about "what if a team doesn't show," because they knew the platform handled it.
What surprised me
The match graph as a data structure outlasted three tournament-format additions. We started with single elim. We added round-robin, double elim, group + playoff. None of the additions touched the matches table or the propagation logic. Each new format was a generator function. The graph stayed the same.
Manual override frequency was higher than expected. About one in five tournaments had an override applied. The format generator was a starting point, not a final answer. Operators trusted the platform more because override was a first-class action, not a "talk to support" path.
The dispute window was the most-tuned parameter. Initially 24 hours; tournament organizers complained that disputes after the next match started were a mess. We moved to a per-match window - disputes had to be raised before the next dependent match was scheduled to start. This required the propagation worker to wait on the dispute window before transitioning dependents to scheduled. Worth the complexity.
What I'd do differently
Per-match score schema validation upfront. We accepted any jsonb for match results and validated in code. A more disciplined approach is to have a per-format result schema (single elim final score, round-robin set scores, league points) defined and validated at the database layer.
Bracket visualisation as a materialised view. Rendering the bracket in the UI involved a lot of recursive walking of the matches table. A materialised view that pre-computed bracket coordinates per format would have made the UI simpler and faster.
Treat re-seeding as a first-class operation, not a generic override. Re-seeding is one of the most common overrides ("the seeds we got are wrong, let's redo round 1"). It should have had its own action with proper UI, not been buried under "override → reseed."
If you are building tournament software, the data structure that wins is a deterministic match graph with first-class overrides. Brackets are not a rendering problem. They are a state-and-event problem with a particularly visual presentation. Build the state and events; the visual falls out.