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.
Top comments (0)