Tournament brackets look like a list of matchups until the participant count is 6, 10, or 14. Then the missing slots need BYEs, seeds need a consistent distribution, and later rounds must wait for actual winners. I built this generator to make those rules visible instead of asking an organizer to fill gaps by hand. The interesting implementation problem is not drawing boxes; it is keeping identity, seeding, and progression correct when the tree is incomplete.
Normalize names before creating matches
The input accepts newlines, commas, and their full-width variants. Trimming and a Set remove duplicate tokens:
const namesList = computed(() => {
const tokens = rawNames.value
.split(/[\n,,;;]+/g)
.map((s) => s.trim())
.filter(Boolean);
return [...new Set(tokens)];
});
The delimiter regex means a pasted list such as Ada, Ben;Cara becomes three names. Empty lines and accidental spaces disappear before bracket math starts. Set preserves the first occurrence while removing exact duplicate strings. That is a product decision as much as a parsing detail: two real players with the same name must add a team name or number themselves, or the generator cannot distinguish them.
Generation refuses fewer than two distinct names. That is better than drawing a one-person “final” and pretending it is a tournament. The original participant list remains text until generation, so editing the textarea does not unexpectedly mutate a bracket that people may already be using.
Build the next power-of-two bracket and place BYEs
Single-elimination trees are easiest to represent when the first round has a power-of-two number of slots. The helper finds that size:
function nextPowerOfTwo(n) {
let size = 1;
while (size < n) size *= 2;
return size;
}
function seedOrder(size) {
let order = [1, 2];
while (order.length < size) {
const nextSize = order.length * 2;
order = order.flatMap((seed) =>
[seed, nextSize + 1 - seed]);
}
return order.slice(0, size);
}
With six entrants, nextPowerOfTwo(6) returns eight, so two slots become BYEs. seedOrder(8) produces a mirrored sequence that places high seeds apart instead of putting seeds 1 and 2 into the same half. The generator maps a seed to an entrant when seed <= entrants.length; otherwise it creates { bye: true }.
The first-round matches are then paired. A match with one real player and one BYE auto-advances. A match with two real players waits for the organizer to click a winner. This distinction is important: a BYE is not a player and should never be selectable as champion. When a winner exists, the component writes it into Math.floor(m / 2) in the next round at m % 2, so match positions—not display text—carry the progression.
There are two seed modes. Shuffle uses a Fisher–Yates-style swap with Math.random(), then the same bracket logic handles the shuffled order. Ordered mode retains the input order before seeding. Neither mode claims official sport-specific seeding; this is a transparent small-event bracket, not a governing-body rules engine.
Winners update a tree, not a flat list
The bracket model keeps a global match index and round-local IDs. That lets the template render labels while selectedWinnerKeys records which participant was chosen for each match. Recomputing the bracket walks rounds from left to right, applies any automatic winner, reads the stored selection, and forwards the winner. If an earlier winner changes, later slots are recalculated instead of leaving stale names in the final.
For an eight-slot example, four first-round results populate two semifinal matches, and those populate one final. A match cannot be selected until it has two real players, which prevents an organizer from choosing a winner before the preceding round exists. This is a small state-machine rule that avoids many “why did the final contain a player who never won?” bugs.
The advancement state is recomputed from the first round forward rather than patched one slot at a time. That matters when an organizer changes an earlier winner: downstream matches are cleared and rebuilt from the new selection, so a participant from an abandoned path cannot remain stuck in a semifinal. It is a modest amount of recalculation for a small bracket, but it is much easier to reason about than trying to undo every dependent update.
Export is a second rendering path
The interactive bracket is regular Vue markup, but PNG export draws a separate canvas. The export computes card and round positions, creates a canvas at twice the logical width and height, then calls ctx.scale(2, 2) before drawing:
const width = padding * 2 + rounds.value.length * cardW
+ (rounds.value.length - 1) * roundGap;
const height = padding * 2
+ rounds.value[0].length * baseGap + 40;
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
ctx.scale(2, 2);
The logical coordinates stay readable while the resulting PNG has more pixels on high-density screens. Printing uses a print area and hides inputs and controls through noPrint classes. This separate path means a CSS change to the interactive view does not automatically change the downloaded image, so both paths deserve a quick visual check.
The limitations are clear: this is single elimination only, no official seeding validation is performed, and shuffle uses browser pseudo-randomness. Participant text is local, but an exported PNG or printed sheet can be shared outside the browser. I turned this implementation into a small free tool: Tournament Bracket Generator.
Top comments (0)