Single-elimination brackets look simple until you ask three questions: where do the byes go, how do you stop the top seeds from meeting in round one, and how do you add a third-place match without breaking the layout. This post walks through how we build a seeded bracket in TypeScript. The code is from the tournament module of a framework-free domain package; snippets are trimmed from the real file.
Step 1: round up to a power of two
A bracket needs a field size of 2, 4, 8, 16... Anything else is rounded up and the missing slots become byes:
export function nextPowerOfTwo(count: number): number { if (count <= 1) return count <= 0 ? 0 : 1; let size = 1; while (size < count) size *= 2; return size; } Six entrants give a bracket of 8, so byeCount is 8 - 6 = 2. The number of real first-round matches is entries - size / 2, which is 2 for six entrants (4 v 5 and 3 v 6). That is useful for scheduling, because a bye is not a match and should not take a court slot.
Step 2: the seeding order
The standard recursive order puts seed 1 opposite seed 16, then 8 v 9, 5 v 12, 4 v 13 and so on. It can be built by repeatedly interleaving each seed with its complement:
export function seedOrder(size: number): number[] { if (size <= 1) return size <= 0 ? [] : [1]; let order = [1]; while (order.length < size) { const complement = order.length * 2 + 1; const next: number[] = []; for (const seed of order) { next.push(seed, complement - seed); } order = next; } return order; } Tracing it:
[1] [1, 2] complement 3 [1, 4, 2, 3] complement 5 [1, 8, 4, 5, 2, 7, 3, 6] complement 9 Each pair of neighbours is one first-round match: (1,8), (4,5), (2,7), (3,6). The property you get from this construction is the one players check first: the top two seeds cannot meet before the final, the top four cannot meet before the semi-finals, the top eight cannot meet before the quarter-finals, and so on down the draw. It falls out of the construction, so there is no separate check to maintain.
Step 3: byes land on the top seeds, for free
Slots are filled by looking each seed up in the entry list. A seed with no entrant becomes a bye:
function firstRoundSlots(entryIds: CompetitorId[], size: number): BracketSlot[] { return seedOrder(size).map((seed) => { const entryId = entryIds[seed - 1]; return entryId === undefined ? ({ kind: "bye" } as BracketSlot) : ({ entryId, kind: "entry" } as BracketSlot); }); } Because the order pairs seed k with seed size + 1 - k, the missing seeds are always the highest numbers, so the byes face the best seeds. With six entrants in a bracket of 8, seeds 7 and 8 are missing, so seed 1 (opposite 8) and seed 2 (opposite 7) get a free first round. No special-case code is needed, and the conventional outcome, a bye as the reward for seeding high, is automatic.
The slot type is a small discriminated union, which also carries the later rounds:
export type BracketSlot = | { entryId: CompetitorId; kind: "entry" } | { kind: "bye" } | { fixtureId: string; kind: "winner_of" } | { fixtureId: string; kind: "loser_of" }; Step 4: build every round up front
knockoutBracket generates all rounds at once. Round 1 pairs adjacent slots; each later round pairs the winner_of references to the previous round's fixtures:
export function knockoutBracket(input: KnockoutInput): KnockoutBracket { const prefix = input.idPrefix ?? "ko"; const entryIds = [...input.entryIds]; const bracketSize = nextPowerOfTwo(entryIds.length); const totalRounds = bracketSize <= 1 ? 0 : Math.log2(bracketSize); const fixtures: BracketFixture[] = []; let slots = firstRoundSlots(entryIds, bracketSize); for (let round = 1; round <= totalRounds; round++) { const nextSlots: BracketSlot[] = []; for (let index = 0; index * 2 + 1 < slots.length; index++) { const sideA = slots[index * 2]; const sideB = slots[index * 2 + 1]; const id = fixtureId(prefix, round, index); fixtures.push({ id, roundNumber: round, sideA, sideB }); nextSlots.push({ fixtureId: id, kind: "winner_of" }); } slots = nextSlots; } // ...optional third-place match, then the result object } Two design choices are worth calling out:
- The input is already in seed order (index 0 is seed 1). Sorting by seed is the caller's job, which keeps this function free of any ranking logic.
- There is no re-seeding between rounds. The bracket is fixed at the draw. That is what makes it printable and lets every player see their whole path to the final.
A fixture whose two sides are an entry and a bye is resolved as a walkover at read time: the entrant advances without playing, and nobody is credited with a win over a bye.
Step 5: the third-place match
Some organisers want a real 3rd and 4th place instead of a joint 3rd. That is one extra fixture, fed by the losers of the two semi-finals. The slot kind loser_of exists for exactly this:
function bronzeFixture( fixtures: readonly BracketFixture[], totalRounds: number, prefix: string ): BracketFixture | null { const [semiA, semiB] = fixtures.filter( (fixture) => fixture.roundNumber === totalRounds - 1 ); if (semiA === undefined || semiB === undefined) return null; return { id: thirdPlaceFixtureId(prefix), roundNumber: totalRounds, sideA: { fixtureId: semiA.id, kind: "loser_of" }, sideB: { fixtureId: semiB.id, kind: "loser_of" }, thirdPlace: true, }; } Details that matter:
- It lives in the final's round, so it can be played at the same time as the final on another court.
- It has its own id (
ko-3pwith the default prefix) and sits outside the bracket's(roundNumber, sequence)positions, so nothing in a later round is computed from it. Any code that lays out the bracket must filter it out with a helper (isThirdPlaceFixtureId), which checks the id suffix. - It is only built from four entrants up:
const THIRD_PLACE_MIN_ENTRIES = 4; export function thirdPlaceApplies(entryCount: number): boolean { return entryCount >= THIRD_PLACE_MIN_ENTRIES; } With three entrants, seed 1 has a bye through the semi-final, so "the loser of that semi" is nobody. The one real beaten semi-finalist is simply third without playing anyone.
What it deliberately does not do
- No double elimination. Without a bronze match, losers are placed by the round they went out in (joint 3rd, joint 5th and so on), which is how a one-day club event is usually reported.
- No protection against clubmates or partners meeting early. Seeding is by seed number only.
- No re-seeding between rounds.
Writing these limits down in the module's header comment keeps the behaviour explicit for the next person who reads it.
Try it
If you just need a draw for a club tournament, there is a free generator at meetandplay.mx/herramientas/generador-cuadro-torneo that uses this seeding.

