Skip to content

Release 2.0.0: round robin and single elimination, plus a bracket overhaul - #1

Merged
nadersafa1 merged 3 commits into
mainfrom
claude/double-elimination-review-7jhpla
Sep 19, 2026
Merged

nadersafa1 merged 3 commits into
mainfrom
claude/double-elimination-review-7jhpla

Conversation

@nadersafa1

@nadersafa1 nadersafa1 commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

The package generated one format; it now generates three, all returning the same match objects so one renderer, one table and one results screen serve all of them. This PR also carries the review pass that came first — four correctness defects, including one that made the published package unloadable in Node.

Three commits: the bracket fixes, the new formats, then review follow-ups.

New formats

generateTournament({ format, ... })   // discriminated union, format as data
generateSingleElimination(options)    // + thirdPlaceMatch
generateDoubleElimination(options)    // unchanged from 1.x
generateRoundRobin(options)           // + legs, groupCount
calculateStandings(options)           // points, tiebreakers, group tables

Round robin — circle-method fixtures, seeded so the top two seeds meet in the final round and round 1 opens with the widest mismatch. Sides split as evenly as the round count allows (exactly even when everyone plays an even number of games, and always even over two legs). Odd fields rest one participant per round rather than producing empty matches. legs replays the fixtures with the sides swapped for home-and-away seasons; groupCount splits the field into snake-seeded groups whose sizes stay within one of each other.

Standings — points, wins, draws, losses, scores and ranks from whatever results exist so far, so the table is correct at any point in a season. Results may carry scores or just a winner (winnerId: null records a draw). The points table is configurable, and tiebreakers apply in order, each only to the rows the previous one left level, the way a real rulebook works — so head-to-head between two teams is not polluted by a third. Participants nothing separates share a rank (1, 2, 2, 4); end the tiebreakers with seed when you need a strict cut for a playoff draw.

Single elimination — with an optional thirdPlaceMatch. Previously reachable only as losersStartRoundsBeforeFinal: 0, which nobody would guess.

Grand final for double elimination — 'none' (the 1.x behaviour, still the default), 'single', or 'reset', where the winners final loser drops to the losers final and the two bracket winners meet, with a bracket reset when the comeback lands.

Fixes

  • Byes stalled the losers bracket. They were only resolved in the first winners round, so any participant count that is not a power of two left matches that could never be played — one waiting on a loser that does not exist, or with no entrants at all — and everything downstream of them stalled. Byes now resolve across the whole bracket in topological order: walkover winners are pre-placed, a walkover no longer reserves a losers bracket slot, and matches that could only ever receive one player are bypassed so the feeding match points at what came after them. Unreachable matches stay in the array with empty slots and no routing, so positions remain stable for rendering.
  • The published package could not be loaded by Node at all. The build emitted ES module syntax with extensionless relative imports into a package with no "type" or "exports" field, so both require() and import failed outside a bundler. It now ships separate ESM and CommonJS builds behind an exports map, checked on every CI run.
  • Rematch prevention degraded above 16 players. Losers from winners round 3 onwards kept their bracket position, so players could meet opponents they had already beaten as early as losers round 4. Each wave of losers is now reordered on a rotating cycle. Over 200 random 64-player tournaments this moves the first possible rematch from losers round 4 to losers round 7 and cuts rematches from ~2.8 to ~0.4 per tournament.
  • Invalid seeds corrupted the bracket silently. Seeds that were not exactly 1..N left participants out of it, and duplicate seeds dropped players without warning. Participants are now ranked by seed, and duplicate seeds, duplicate registration ids, a repeating idFactory and other invalid options throw.
  • Tie-unsafe qualifier recipes. The documented "top two from every group" snippet filtered on rank <= 2, which can return three rows from one group, and fed them straight into a bracket. The recipes now end the tiebreakers with seed and explain the alternative.

Breaking changes

  1. bracketType gains 'grandFinal' and 'roundRobin'. Exhaustive switch statements need the new cases, even though neither value appears unless asked for.
  2. Every match carries group and leg (null and 1 outside a round robin).
  3. Types renamed, with the old names kept as deprecated aliases: BracketMatch → TournamentMatch, BracketType → MatchType, GeneratorOptions → DoubleEliminationOptions.
  4. The losers bracket layout changed for 32+ participants as part of the rematch fix. In-flight tournaments should finish on the version that created them.

generateDoubleElimination takes the same options and produces the same brackets for 16 participants or fewer, so most upgrades are just the install. Full notes in Migrating from 1.x.

Testing

328 tests, up from 23. Beyond the unit tests:

  • An invariant suite plays every bracket size out with random results and checks that nothing stalls, that every player but the champion accumulates exactly two losses, that no slot has two feeders, and that rematches stay rare and late.
  • A round robin suite checks across field sizes that every pair meets exactly once per leg, nobody is double-booked in a round, and the sides stay balanced.
  • npm run test:package loads the built output through both import and require, so a broken publish fails CI instead of someone else's project.
  • Every numeric claim in the README and EXAMPLES was verified against real output before being written down.

CI runs typecheck, tests, build and the entry-point check on Node 18, 20 and 22.

Docs and website

README rewritten around the three formats, with a format-choosing table, the shared match shape, a result-propagation recipe and migration notes. EXAMPLES covers each format plus the group-stage-into-playoff-bracket flow. The demo script prints any format (npm run demo -- round-robin 10 groups=2).

The marketing page demos all five configurations with a live standings table, and was checked at desktop and mobile widths for layout overflow and console errors. Its copy, feature grid and usage examples now cover round robin, standings and generateTournament.

Release

package.json is at 2.0.0 — the next major over the published 1.2.2, which is what the breaking changes above require. npm publish --dry-run packs 148 files / 54.7 kB with both entry points resolving.

Note on naming

The npm name stays double-elimination — renaming means publishing a new package. The README opens by explaining that the name is historical and the package now covers three formats. Happy to revisit if you would rather publish under a broader name.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K

… final

Senior review pass over the bracket generator. Four defects, one new feature,
and the supporting test and packaging work.

Fixes:

- Byes only advanced players in the first winners round, so any participant
  count that is not a power of two left the losers bracket with matches that
  could never be played — one waiting on a loser that does not exist, or with no
  entrants at all — stalling everything downstream. Byes are now resolved
  across the whole bracket in topological order: walkover winners are
  pre-placed, a walkover no longer reserves a losers bracket slot, and matches
  that could only ever receive one player are bypassed so the feeding match
  points at what came after them. Unreachable matches stay in the array with
  empty slots and no routing so positions remain stable for rendering.

- The published package could not be loaded by Node at all. The build emitted
  ES module syntax with extensionless relative imports into a package with no
  "type" or "exports" field, so both require() and import failed outside a
  bundler. The package now ships separate ESM and CommonJS builds behind an
  exports map, checked on every CI run by scripts/verify-package.mjs.

- Losers from winners round 3 onwards kept their bracket position, which let
  players meet opponents they had already beaten as early as losers round 4.
  Each wave of losers is now reordered on a rotating cycle. Over 200 random
  64-player tournaments this moves the first possible rematch from losers round
  4 to losers round 7 and cuts rematches from ~2.8 to ~0.4 per tournament.
  Brackets of 16 or fewer are unchanged.

- Seeds that were not exactly 1..N silently left participants out of the
  bracket. Participants are now ranked by seed, and duplicate seeds, duplicate
  registration ids, a repeating idFactory and other invalid options throw.

Adds the grandFinal option ('none' by default, 'single', or 'reset'), which
drops the winners final loser into the losers final and runs a grand final,
optionally with a bracket reset.

Test suite grows from 23 to 183 tests, including an invariant suite that plays
every bracket size out with random results and checks that nothing stalls, that
every player but the champion accumulates two losses, and that rematches stay
rare and late.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K
The package generated one format; it now generates three, all returning the
same match objects so one renderer, one table and one results screen serve all
of them.

Round robin (`generateRoundRobin`):

- Circle-method fixtures, seeded so the top two seeds meet in the final round
  and round 1 opens with the widest mismatch
- Sides split as evenly as the round count allows — exactly even when everyone
  plays an even number of games, off by one otherwise
- Odd fields rest one participant per round rather than producing empty matches
- `legs` replays the fixtures with the sides swapped, for home-and-away seasons
- `groupCount` splits the field into snake-seeded groups, each playing its own
  round robin, with group sizes within one of each other

Standings (`calculateStandings`):

- Points, wins, draws, losses, scores and ranks from whatever results exist so
  far; fixtures without a result count as unplayed
- Results may carry scores or just a winner (`winnerId: null` records a draw)
- Configurable points table, and tiebreakers applied in order — each only to
  the rows the previous one left level, as a real rulebook works
- Head-to-head, score difference, score for, wins and seed; participants
  nothing separates share a rank

Single elimination (`generateSingleElimination`) with an optional
`thirdPlaceMatch`, previously reachable only through
`losersStartRoundsBeforeFinal: 0`, and `generateTournament({ format, ... })`
for applications that store the format as data.

Breaking changes, all covered in the migration notes:

- `bracketType` gains `'grandFinal'` and `'roundRobin'`
- every match carries `group` and `leg`
- `BracketMatch` → `TournamentMatch`, `BracketType` → `MatchType`,
  `GeneratorOptions` → `DoubleEliminationOptions`, old names kept as aliases

Also here: the unreleased bye, packaging, routing and validation fixes now ship
as part of 2.0.0, the README and EXAMPLES are rewritten around the three
formats, the demo prints any of them, and the website demos them all with a
live standings table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K
@nadersafa1 nadersafa1 changed the title Fix bye propagation, packaging, and losers bracket routing; add grand final Release 2.0.0: round robin and single elimination, plus a bracket overhaul Sep 12, 2026
Review follow-ups on the 2.0.0 branch.

- The website's sample scoreline generator took a single step of a linear
  congruential generator on consecutive inputs, which walks a short cycle: every
  sample match came out a two-goal win with alternating sides and never a draw,
  so the demo standings table always showed a draw column of zeroes. Mix the
  bits instead, which gives varied scorelines and about one draw in four.

- The documented "top two from every group" recipe filtered on `rank <= 2`,
  which is unsound by the library's own semantics: participants nothing
  separates share a rank, so a three-way tie returns three rows, and the next
  snippet fed them straight into a bracket. The recipes now end the tiebreakers
  with `seed`, which is unique and therefore always decides, and say what
  happens when you leave it out. Covered by a test that draws every match in a
  group stage and asserts the ranks still come out 1, 2, 3, 4.

Also: the marketing page's feature heading and footer said "brackets", which no
longer covers round robin, and the usage examples had no `generateTournament`
tab despite the section promising one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6MiM4MbcS4dhfwzbZBi4K
@nadersafa1
nadersafa1 merged commit c44a546 into main Sep 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants