← /writing/tech·2026 · 02 · 08·8 min read

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.

companion: Building a Tournament Management Platform for Local Sports Communities

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:

sqlcreate 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[]:

tstype 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:

sqlcreate 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:

FromToTrigger
pendingscheduledboth teams resolved (originally seeded or sourced from completed matches)
scheduledliveorganizer marks the match in progress
scheduledwalkoverone team didn't show; opponent advances
scheduledforfeita team withdrew before the match
livecompletedorganizer enters a result
completeddisputeda player or coach raises an objection within the dispute window
disputedconfirmedorganizer resolves the dispute, possibly amending the result
completedconfirmeddispute 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:

tsasync 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.