Generating Tournament Brackets in Vanilla JS: Seeding, Byes, and the Loser's Bracket
I run a small rec-league circuit, and every season someone redraws the playoff bracket by hand, gets a seed wrong, and quietly ruins the loser's bracket. So I sat down and wrote a bracket generator — plain JavaScript, no
I run a small rec-league circuit, and every season someone redraws the playoff bracket by hand, gets a seed wrong, and quietly ruins the loser's bracket. So I sat down and wrote a bracket generator — plain JavaScript, no framework, no backend, no build step. The interesting part wasn't the UI. It was that three things everyone hand-waves in a bracket — seeding order, byes, and the loser's bracket — each hide an actual algorithmic decision.
Here's what I ended up with, with the real code.
1. Seeding is a recursion, not a shuffle
A seeded single-elimination bracket for s = 2^r teams has a fixed constraint: in every round, the sum of the two seeds playing each other should be 2^r + 1 (1 vs 16, 2 vs 15, ... 8 vs 9 in a 16-team bracket). If you just shuffle or sort naively, you break that invariant and the #1 and #2 seeds can meet in round one.
The standard fix is the "nested" construction: take the order for s/2, then for each seed x in it, emit x and s + 1 - x:
function seedOrder(s) {
let order = [1, 2];
while (order.length < s) {
const size = order.length * 2;
const next = [];
for (const x of order) next.push(x, size + 1 - x);
order = next;
}
return order;
}
seedOrder(8); // [1, 8, 4, 5, 2, 7, 3, 6]
Round one pairs are adjacent entries, and every pairing sums to s + 1. Four lines, and it's testable against a printed NCAA bracket forever.
2. Byes are slots, not special cases
Non-power-of-two team counts (5, 6, 7, 9...) mean byes. The tempting approach is a bunch of if (hasBye) branches scattered through the render code. That way lies pain, because a bye affects three things at once: who advances, which match doesn't exist, and what the "loser" of a bye is (nothing — there is no game, so nobody drops into the loser's bracket).
I made a bye just another slot:
if (a && b) {
r1.push({ id: `W${gid}`, slots: [a, b], winner: `W${gid}`, loser: `L${gid}` });
} else {
const adv = a ?? b;
r1.push({ id: null, bye: true, slots: [adv, { kind: 'bye' }], winner: adv, loser: null });
}
A bye round has id: null, loser: null, and the advancing team's slot object is reused directly in the next round. The renderer treats it uniformly, and — this mattered more than I expected — the double-elimination logic stays correct for free, because "bye losers" simply never enter the losers pipeline. One representation, zero special cases downstream.
3. The loser's bracket is a two-stage pipeline, not a tree
This was the surprise. A winners bracket is a tree: every match feeds exactly one parent. A losers bracket is not any tree I could draw first and fill in later, because the set of teams arriving each round depends on who lost this round in the winners bracket.
What works is thinking of it as a pool with a two-stage pump, once per winners round:
- Minor stage: the survivors currently in the losers pool play each other. If the count is odd, the last one gets a pass.
- Major stage: the winners of those minor matches face the teams that just dropped out of the winners bracket this round. Pair them in order. If more teams dropped than survived, the extra drop-ins pass through to the next pool.
const pairUp = (xs) => {
const matches = [];
const rest = [...xs];
while (rest.length >= 2) matches.push(lMatch(rest.shift(), rest.shift()));
return { matches, survivors: [...matches.map((m) => m.winner), ...rest] };
};
// per winners round k:
const minor = pairUp(pool);
// survivors[i] vs drops[i] for i < min(survivors, drops);
// leftovers (either side) carry into the next pool unchanged
pool = [...majorWinners, ...minorSurvivors.slice(n), ...drops.slice(n)];
Run that loop once per winners round and the pool drains to exactly one team: your losers bracket champion. Two properties fall out of the construction and are worth asserting in tests: a team is never asked to play twice in the same "round", and the total game count for n teams is at most 2n - 1 (one fewer if your format skips the grand-final reset).
Byes from step 2 flow through here too — an odd-sized drop creates a carry-through, which is exactly the drops.slice(n) term.
Round robins are the easy one
For completeness: a round robin schedule is the circle method. Fix team 1, rotate the rest by one each round, pair mirror positions:
players = [players[0], players[size - 1], ...players.slice(1, size - 1)];
For an odd number of teams, append a dummy null to the ring; whoever pairs with the dummy has a bye that round. After trying to be clever with modular arithmetic twice, the rotate-and-mirror version is the one I could still read in six months.
Why all of this is pure functions
The whole engine — validation, the three builders, seed order, share-URL encode/decode — is pure functions over a config object. The same file runs in the browser, gets imported by the static-site baker, and is loaded directly by the Node test suite. No DOM, no fetch, no state. That one decision is why testing "5-team double elimination places byes correctly" is a one-liner instead of a browser automation job.
I packaged all of this into a free, print-first bracket generator (single/double/round robin, 3–128 teams, byes placed automatically, nothing stored) if you want to see the output or steal the code: bracketree.com
And if you draw yours on paper anyway: pencil in the loser's bracket. It will change.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.