npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

double-elimination

v2.0.0

Published

Tournament generator for double elimination, single elimination and round robin — seeding, byes, grand finals, group stages and standings

Readme

double-elimination

npm version npm weekly downloads License: MIT TypeScript GitHub stars Live Demo

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

  • ✅ 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-elimination

Works 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 recorded

A 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.registration2Id

A 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 rounds

Scheduling

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 - 1 for an even field, n for 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 — registration1Id is 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 A
const 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 2

Three 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 loserTo is null, 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:

  1. bracketType gained 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.
  2. Every match carries group and leg. They are null and 1 in brackets. If you persist matches with a strict schema, add the columns or drop the fields.
  3. Types were renamed, with the old names kept as deprecated aliases: BracketMatch → TournamentMatch, BracketType → MatchType, GeneratorOptions → DoubleEliminationOptions.
  4. 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