double-elimination
v2.0.0
Published
Tournament generator for double elimination, single elimination and round robin — seeding, byes, grand finals, group stages and standings
Maintainers
Keywords
Readme
double-elimination
Generate a whole tournament from a list of participants — double elimination, single elimination or round robin — with standard seeding, byes handled end to end, optional grand finals, group stages and league standings.
Every format returns the same match objects, so one renderer, one database table and one results screen serve all three.
import { generateTournament } from 'double-elimination'
const matches = generateTournament({
format: 'double-elimination',
eventId: 'spring-major',
participants,
idFactory: () => crypto.randomUUID(),
grandFinal: 'reset',
})Why the name? The package started as a double elimination generator and keeps the name on npm. It now covers three formats — see Choosing a format.
Table of Contents
- Features
- Installation
- Quick Start
- Choosing a Format
- The Match Shape
- API
- Double Elimination
- Round Robin
- Seeding
- Bye Handling
- Performance
- Migrating from 1.x
- Contributing
- License
Features
- ✅ Three formats, one shape — double elimination, single elimination and
round robin all return the same
TournamentMatch[] - ✅ Standard seeding — seeds 1 and 2 can only meet in the final, seeds 1–4 only in the semifinals, and so on
- ✅ Byes handled end to end — odd participant counts run to completion instead of stalling on an opponent who never arrives
- ✅ Grand final and bracket reset — optional, so the losers bracket winner gets a real shot at the title
- ✅ Rematch prevention — rotating loser routing keeps players away from opponents they already beat
- ✅ Round robin done properly — seeded fixtures, balanced sides, multi-leg seasons, snake-seeded group stages
- ✅ Standings with tiebreakers — points, head-to-head, score difference, score for, wins, seed — in the order your rules say
- ✅ Validated input — duplicate seeds, duplicate ids and repeated match ids throw instead of corrupting the tournament
- ✅ Zero dependencies, ESM and CommonJS builds, full TypeScript types
Installation
npm install double-eliminationWorks on Node 18+ and in any bundler; import and require both resolve.
Quick Start
Every generator takes the same three things: an eventId copied onto each
match, your participants, and an idFactory that returns unique match ids.
import {
generateSingleElimination,
generateDoubleElimination,
generateRoundRobin,
calculateStandings,
} from 'double-elimination'
const participants = [
{ registrationId: 'player-1', seed: 1 },
{ registrationId: 'player-2', seed: 2 },
{ registrationId: 'player-3', seed: 3 },
{ registrationId: 'player-4', seed: 4 },
]
const options = {
eventId: 'tournament-1',
participants,
idFactory: () => crypto.randomUUID(),
}
// One loss and you are out
const cup = generateSingleElimination({ ...options, thirdPlaceMatch: true })
// Two losses to go out, with a grand final and bracket reset
const major = generateDoubleElimination({ ...options, grandFinal: 'reset' })
// Everyone plays everyone, home and away
const league = generateRoundRobin({ ...options, legs: 2 })
// ...and once results come in
const table = calculateStandings({
matches: league,
results: [{ matchId: league[0].id, score1: 2, score2: 1 }],
})Choosing a Format
| | Single elimination | Double elimination | Round robin | | ---------------------- | -------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------- | | Losses to go out | 1 | 2 | — everyone plays everyone | | Matches for 16 players | 15 | 28, or 30 with a grand final | 120 (240 over two legs) | | Rounds for 16 players | 4 | 4 winners + 5 losers | 15 | | Ranks produced | 1st, 2nd (+3rd with a third place match) | 1st – 4th | a full table | | Good for | tight schedules, large fields, knockout days | fair results without doubling the field's time | leagues, group stages, small fields who came to play | | Bad luck early | ends your day | costs you the winners bracket | costs you three points |
Mixing formats is common and fully supported: run generateRoundRobin with
groupCount for the group stage, then feed the qualifiers into
generateDoubleElimination as a new tournament with fresh seeds.
The Match Shape
Every generator returns TournamentMatch[]:
interface TournamentMatch {
id: string
eventId: string
round: number // 1-based, counted within bracketType (and group)
matchNumber: number // 1-based within the round
bracketPosition: number // 0-based within the round, top to bottom
registration1Id: string | null
registration2Id: string | null
winnerTo: string | null // match the winner advances to
winnerToSlot: number | null // slot (1 or 2) they take there
loserTo: string | null // match the loser drops to
loserToSlot: number | null
bracketType: 'winners' | 'losers' | 'grandFinal' | 'roundRobin'
group: number | null // 0-based group index in a group stage
leg: number // 1-based; above 1 only in multi-leg round robins
}| Field | Brackets | Round robin |
| ------------------------------------- | ------------------------------ | ---------------------------------------- |
| registration1Id / registration2Id | filled in as rounds are played | both known up front |
| winnerTo / loserTo | where the players go next | always null |
| group | null | group index, or null for a single pool |
| leg | 1 | which time round the field this is |
Applying a result
Brackets carry their own wiring, so recording a result is the same three lines whatever the format:
const applyResult = (
matches: TournamentMatch[],
matchId: string,
winnerId: string
) => {
const match = matches.find((m) => m.id === matchId)!
const loserId =
winnerId === match.registration1Id
? match.registration2Id
: match.registration1Id
const place = (
targetId: string | null,
slot: number | null,
who: string | null
) => {
if (!targetId || !slot || !who) return
const target = matches.find((m) => m.id === targetId)!
if (slot === 1) target.registration1Id = who
else target.registration2Id = who
}
place(match.winnerTo, match.winnerToSlot, winnerId)
place(match.loserTo, match.loserToSlot, loserId)
}Round robin matches have no routing — rank them with
calculateStandings instead.
API
generateTournament(options)
Generates whichever format options.format names. Use it when the format is
data — a column in your database, a value from a form — rather than something
known when the code is written.
generateTournament({
format: 'round-robin', // 'single-elimination' | 'double-elimination' | 'round-robin'
eventId: 'league-2026',
participants,
idFactory: () => crypto.randomUUID(),
legs: 2, // format-specific options come along too
})The options are a discriminated union, so TypeScript offers exactly the options that format accepts and rejects the others.
Shared options
| Option | Type | Description |
| -------------- | --------------- | ------------------------------------------------------ |
| eventId | string | Identifier for the tournament, copied onto every match |
| participants | Participant[] | { registrationId, seed }, at least 2 |
| idFactory | () => string | Returns a unique id per match |
Seeds must be unique but need not be 1..N — participants are ranked by seed,
so [10, 20, 30] seeds identically to [1, 2, 3]. Duplicate seeds, duplicate
registrationIds and a repeating idFactory all throw rather than quietly
producing a broken tournament.
generateSingleElimination(options)
| Option | Type | Default | Description |
| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------ |
| thirdPlaceMatch | boolean | false | Adds a match between the two semifinal losers, returned with bracketType: 'losers' |
Produces bracketSize - 1 matches — one more with thirdPlaceMatch — where
the bracket size is the next power of two at or above the participant count.
generateDoubleElimination(options)
| Option | Type | Default | Description |
| ------------------------------ | ------------------------------- | ---------- | ------------------------------------------------------------------------ |
| grandFinal | 'none' \| 'single' \| 'reset' | 'none' | Whether the bracket winners meet, and whether a bracket reset can follow |
| losersStartRoundsBeforeFinal | number | all rounds | Start the losers bracket later, eliminating early losers outright |
See Double Elimination for the structure it produces.
generateRoundRobin(options)
| Option | Type | Default | Description |
| ------------ | -------- | ------- | -------------------------------------------------------------------------- |
| legs | number | 1 | How many times everyone plays everyone; later legs swap sides |
| groupCount | number | 1 | Split the field into snake-seeded groups, each playing its own round robin |
See Round Robin for scheduling details.
calculateStandings(options)
Builds a standings table from whatever results exist so far.
const table = calculateStandings({
matches, // the fixtures to rank
results, // [{ matchId, score1, score2 }] or [{ matchId, winnerId }]
participants, // optional: enables the 'seed' tiebreaker
points: { win: 3, draw: 1, loss: 0 },
tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins'],
})| Option | Type | Default | Description |
| -------------- | ----------------------- | ------------------------------------------------------- | ------------------------------------------------------------- |
| matches | TournamentMatch[] | — | The fixtures to rank. Group stages are ranked per group |
| results | MatchResult[] | — | Results so far; fixtures without one count as unplayed |
| participants | Participant[] | [] | Needed only for the seed tiebreaker and to order level rows |
| points | Partial<PointsConfig> | { win: 3, draw: 1, loss: 0 } | Points per outcome |
| tiebreakers | Tiebreaker[] | ['headToHead', 'scoreDifference', 'scoreFor', 'wins'] | Applied in order to participants level on points |
Returns one Standing per participant per group, ordered by group and then by
rank:
interface Standing {
registrationId: string
group: number | null
rank: number // 1-based; level participants share a rank
played: number
won: number
drawn: number
lost: number
scoreFor: number
scoreAgainst: number
scoreDifference: number
points: number
}Results come in two shapes, and you can mix them:
{ matchId, score1: 3, score2: 1 } // scores: higher wins, equal is a draw
{ matchId, winnerId: 'player-7' } // outcome only
{ matchId, winnerId: null } // a draw with no score recordedA result naming an unknown match, a duplicate result, half a scoreline, or a winner who did not play in that match all throw.
Double Elimination
Placements
By default there is no grand final: the winners final decides 1st/2nd and the losers final decides 3rd/4th.
| Match | Winner | Loser | | --------------------- | --------- | --------- | | Winners bracket final | 1st place | 2nd place | | Losers bracket final | 3rd place | 4th place |
With grandFinal: 'single' or 'reset' it runs as a standard double
elimination instead — the winners final loser drops into the losers final, and
the two bracket winners meet:
| Match | Winner | Loser | | --------------------- | ------------- | -------------- | | Winners bracket final | → grand final | → losers final | | Losers bracket final | → grand final | 3rd place | | Grand final | 1st place | 2nd place |
Match counts
For a bracket size B (the next power of two at or above the participant
count):
| | Winners | Losers | Grand final | Total |
| ---------------------- | ------- | ------- | ------------------- | -------- |
| grandFinal: 'none' | B - 1 | B - 3 | — | 2B - 4 |
| grandFinal: 'single' | B - 1 | B - 2 | 1 | 2B - 2 |
| grandFinal: 'reset' | B - 1 | B - 2 | 2 (one conditional) | 2B - 1 |
| Participants | Bracket size | No grand final | With 'single' |
| ------------ | ------------ | -------------- | --------------- |
| 4 | 4 | 4 | 6 |
| 5–8 | 8 | 12 | 14 |
| 9–16 | 16 | 28 | 30 |
| 17–32 | 32 | 60 | 62 |
Matches are returned winners bracket first, then losers bracket, then grand
final, each ordered by round and then by bracketPosition.
Match routing
Winners bracket — winner of position P goes to the next round at position
⌊P/2⌋, slot (P % 2) + 1. The loser drops to the losers bracket; without a
grand final the winners final loser is 2nd place, and a walkover has no loser
at all, so its loserTo is null.
Losers bracket — round 1 pairs off the first wave of losers. After that, even rounds take a fresh wave of winners bracket losers into slot 2 and hold as many matches as the round before them; odd rounds are played between losers bracket survivors only, halving the match count.
Crossover ordering — a player dropping into the losers bracket must not immediately run into someone they already beat, so each wave of losers is reordered before it drops in, and the ordering rotates from wave to wave:
| Wave of losers | Ordering |
| -------------- | --------------------------------- |
| 1st | paired up (⌊position / 2⌋) |
| 2nd | reversed |
| 3rd | reversed and shifted by half |
| 4th | shifted by half |
| 5th | unchanged, then the cycle repeats |
Rotating matters most in large brackets. Measured over 200 random 64-player tournaments, it pushes the first possible rematch from losers round 4 out to losers round 7 and cuts rematches from ~2.8 per tournament to ~0.4. A rematch in the last losers rounds is unavoidable in any double elimination bracket.
Grand final and bracket reset
const matches = generateDoubleElimination({
eventId: 'event-1',
participants,
idFactory: () => crypto.randomUUID(),
grandFinal: 'reset',
})Enabling a grand final adds one losers bracket round — the losers final, where
the winners final loser enters — plus the grand final itself, returned with
bracketType: 'grandFinal'.
With 'reset' the grand final is two matches, round: 1 and round: 2. Match
2 is only played when the losers bracket representative wins match 1;
otherwise the winners bracket representative is champion with an unbeaten
record and match 2 is dropped. Both finalists are routed into it (winnerTo
into slot 1, loserTo into slot 2) so the usual propagation works unchanged —
your code decides whether it happens:
const [grandFinal, reset] = matches
.filter((m) => m.bracketType === 'grandFinal')
.sort((a, b) => a.round - b.round)
// slot 2 of the grand final is the losers bracket representative
const resetRequired = grandFinalWinnerId === grandFinal.registration2IdA grand final needs a losers bracket, so it cannot be combined with
losersStartRoundsBeforeFinal: 0, and needs at least 3 participants.
Delayed losers bracket
By default every loser except the finalist drops to the losers bracket. Use
losersStartRoundsBeforeFinal to open it later, so early losers are eliminated
outright — useful when a full double elimination would not fit the schedule.
// 16 players: Ro16 is single elimination, QF and SF losers get a second life
const matches = generateDoubleElimination({
eventId: 'event-1',
participants,
idFactory: () => crypto.randomUUID(),
losersStartRoundsBeforeFinal: 2,
})| WB round | Name | Loser's fate | | -------- | ----- | ----------------------- | | Round 1 | Ro16 | eliminated | | Round 2 | QF | drops to losers round 1 | | Round 3 | SF | drops to losers round 2 | | Round 4 | Final | 2nd place |
| Value | Result |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| 0 | Pure single elimination — prefer generateSingleElimination |
| 1 | Single elimination with a third place match (needs at least 3 participants) |
| 2+ | Delayed double elimination |
| winnersRounds - 1 | The default: full double elimination |
Round Robin
Everyone plays everyone. Both participants are known when the fixture is
generated, so there is no routing to follow — record results and rank them with
calculateStandings.
const fixtures = generateRoundRobin({
eventId: 'league-2026',
participants, // 10 clubs
idFactory: () => crypto.randomUUID(),
legs: 2, // home and away
})
// 90 matches across 18 roundsScheduling
Fixtures are built with the circle method: one entrant stays put while the rest rotate around them, which pairs everyone exactly once in the fewest rounds possible.
- Rounds per leg —
n - 1for an even field,nfor an odd one - Matches per round —
⌊n / 2⌋ - Odd fields — each participant sits out exactly one round; a rest is simply the absence of a fixture, never a match with an empty slot
- Seeded — entrants enter the circle in seed order, so the top two seeds meet in the final round and round 1 opens with the widest mismatch
- Balanced sides —
registration1Idis the "home" side, and the schedule splits sides as evenly as the round count allows: exactly even when everyone plays an even number of games, off by one otherwise
Legs
legs: 2 replays every fixture with the sides swapped, which is how a
home-and-away season is built and leaves every participant with a perfectly even
split of sides. Round numbering continues across legs (a 6-team, 2-leg season
runs rounds 1–10), and each match carries the leg it belongs to.
Group stages
groupCount splits the field into groups that each play their own round robin.
Participants are distributed by snake seeding, so group strength stays even:
Seeds 1 2 3 4 → groups A B C D
Seeds 5 6 7 8 → groups D C B Aconst groupStage = generateRoundRobin({
eventId: 'world-cup',
participants, // 32 teams
idFactory: () => crypto.randomUUID(),
groupCount: 8, // 8 groups of 4
})
const groupA = groupStage.filter((m) => m.group === 0)Group sizes stay within one participant of each other, rounds are numbered from
1 within each group, and no fixture ever crosses groups. group is null when
there is only one pool.
Standings and tiebreakers
const table = calculateStandings({
matches: groupStage,
results,
participants, // required by the 'seed' tiebreaker below
// Ranks are shared when nothing separates two participants, so end with
// 'seed' whenever you need a strict cut — seeds are unique, so it always
// decides, and every rank becomes a distinct position.
tiebreakers: ['headToHead', 'scoreDifference', 'scoreFor', 'wins', 'seed'],
})
const qualifiers = table.filter((row) => row.rank <= 2)Leave seed out and a three-way tie really does give you three rows at rank 1 —
which is the honest answer, and the one to show a human before a playoff draw.
Rows are ranked on points first, then by each tiebreaker in turn — and each tiebreaker only applies to the rows the previous one left level, exactly as a real competition rulebook works:
| Tiebreaker | Compares |
| ----------------- | --------------------------------------------------------------------------------------------- |
| headToHead | points, then score difference, in the matches the tied participants played against each other |
| scoreDifference | scoreFor - scoreAgainst |
| scoreFor | total scored |
| wins | number of wins |
| seed | the stronger seed ranks higher — a deterministic last resort |
Participants nothing can separate share a rank (1, 2, 2, 4) and are listed
strongest seed first. To follow a rulebook that puts goal difference before
head-to-head, just say so:
calculateStandings({
matches,
results,
tiebreakers: ['scoreDifference', 'scoreFor', 'headToHead'],
})Seeding
Brackets use standard tournament seeding, so the strongest seeds are kept apart for as long as possible:
- Seeds 1 and 2 can only meet in the final
- Seeds 1–4 can only meet in the semifinals or later
- Seeds 1–8 can only meet in the quarterfinals or later
For 8 participants, round 1 is 1v8, 4v5, 2v7, 3v6. For 32, seed 1 leads the
top half, seed 2 the bottom half, and seeds 3–4 sit in the opposite quarters
from them.
Participants are ranked by seed before placement, so seed values only need to be
unique and correctly ordered — [10, 20, 30] and [1, 2, 3] produce the same
tournament. Byes go to the strongest seeds.
Bye Handling
When the participant count isn't a power of 2, byes are created and resolved at generation time — through the whole bracket, not just the first round:
// 7 participants in a bracket of 8 = 1 bye
const matches = generateDoubleElimination({
eventId: 'event-1',
participants, // seeds 1-7
idFactory: () => crypto.randomUUID(),
})
// Seed 1 vs seed 8 (missing) = seed 1 is already placed in round 2Three things follow from a bye, and all of them are handled for you:
- The advancing player is pre-placed. Their slot in the next match is filled before the bracket is returned.
- A walkover produces no loser. The match's
loserToisnull, so nothing waits on a loser that never arrives. - Losers bracket matches that would only ever get one player are skipped. The feeding match is re-pointed at whatever came after them, so the losers bracket always runs to completion.
A match that nobody can reach stays in the returned array with empty slots and no routing, which keeps round and position numbering stable for rendering. The question your UI actually needs answered is whether an empty slot will ever be filled — a slot nobody feeds and nobody occupies never will:
const fed = new Set(
matches.flatMap((m) => [
m.winnerTo ? `${m.winnerTo}#${m.winnerToSlot}` : null,
m.loserTo ? `${m.loserTo}#${m.loserToSlot}` : null,
])
)
const willFill = (match: TournamentMatch, slot: 1 | 2) =>
(slot === 1 ? match.registration1Id : match.registration2Id) !== null ||
fed.has(`${match.id}#${slot}`)
const statusOf = (match: TournamentMatch) => {
const [one, two] = [willFill(match, 1), willFill(match, 2)]
if (!one && !two) return 'unused' // byes emptied it out; skip it
if (!one || !two) return 'walkover' // whoever arrives advances unopposed
return 'playable'
}Most walkovers are resolved for you, with the advancing player already placed in the next match. The one that cannot be is a walkover whose entrant is still unknown — a bracket so small that the bye reaches the last match, such as the third place match in a 3-participant tournament. Treat it as a walkover for whoever turns up.
Round robins need none of this: an odd field simply means each participant rests for one round, and a rest is the absence of a fixture rather than an empty match.
Performance
Generation is linear in the number of matches produced, with no runtime dependencies. Measured on Node 22, best of 15 runs:
| Participants | Single elimination | Double elimination | Round robin | | ------------ | ------------------ | ------------------ | ------------------------ | | 32 | 0.1 ms | 0.2 ms | 0.5 ms (496 matches) | | 128 | 0.4 ms | 0.6 ms | 2.8 ms (8,128 matches) | | 512 | 0.9 ms | 1.4 ms | 113 ms (130,816 matches) | | 4096 | 5.7 ms | 15.2 ms | — |
A round robin is quadratic by nature — 512 participants really is 130,816
fixtures — which is exactly why large fields use groupCount. Building
standings from 32,640 played fixtures takes about 36 ms.
Migrating from 1.x
generateDoubleElimination still takes the same options and produces the same
brackets, so most upgrades are just npm install double-elimination@2. Four
things changed:
bracketTypegained values. It is now'winners' | 'losers' | 'grandFinal' | 'roundRobin'. TypeScript code that exhaustively narrows on it needs the new cases, even if you never enable them.- Every match carries
groupandleg. They arenulland1in brackets. If you persist matches with a strict schema, add the columns or drop the fields. - Types were renamed, with the old names kept as deprecated aliases:
BracketMatch→TournamentMatch,BracketType→MatchType,GeneratorOptions→DoubleEliminationOptions. - The 1.x losers bracket layout changed for 32+ participants as part of the rematch fix, and byes now resolve across the whole bracket. Finish in-flight tournaments on the version that created them.
Single elimination used to be spelled losersStartRoundsBeforeFinal: 0. That
still works, but generateSingleElimination says what it means:
// 1.x
generateDoubleElimination({ ...options, losersStartRoundsBeforeFinal: 1 })
// 2.x
generateSingleElimination({ ...options, thirdPlaceMatch: true })Contributing
Contributions are welcome! Please read our Contributing Guide for details on our code of conduct and the process for submitting pull requests.
More worked examples live in EXAMPLES.md, and
npm run demo -- round-robin 10 groups=2 prints any tournament this package can
generate.
Ways to Contribute
- 🐛 Report bugs
- 💡 Suggest new features
- 📝 Improve documentation
- 🔧 Submit pull requests
- ⭐ Star the repository
License
MIT
