Ned Hermann

Blog / Projects

Building the goatformat.com tournament engine

A bracket as the engine stores it: matches are nodes, results route along edges, byes kill slots ahead of play.
winnerslosersbyebyefinal

A bracket as the engine stores it: matches are nodes, results route along edges, byes kill slots ahead of play.

A tournament engine for goatformat.com

goatformat.com is the home of GOAT Format, retro Yu-Gi-Oh played on the 2005 card pool.1 Its weekly tournaments run over Discord: a bot takes signups and results.

For six years the brackets lived on Challonge, a hosted bracket service.2 Players went there for pairings and standings, and the bot and our database worked around it. I replaced it with our own tournament engine. At its core is the bracket engine: a TypeScript package that turns reported results into pairings, brackets, and standings, verified against captured Challonge brackets with 4,440 playouts.

Now: a result reported in Discord runs through the engine, the bot replies with new pairings, and the site's bracket and standings update.
Discordgoatformat.com# goat-tournamentsplayer A8:02 PM/report winbracket botAPPRound 4 pairingsplayer A vs player Bplayer C vs player Dgoatformat.com/eventsround 4standingsplayer B 9player A 9player C 9player A 12 ↑player B 9player C 9tournament enginepure functionsresultpairingsrenders

Now: a result reported in Discord runs through the engine, the bot replies with new pairings, and the site's bracket and standings update.

What was wrong with Challonge

Before the engine, Challonge hosted the bracket and our database held the players and results.

  • Challonge can't compute Mantis 2.0, the tiebreaker our scene uses to rank tied records by opponent strength3, so standings were wrong.
  • Bracket on Challonge, everything else in our database: the two disagreed constantly, and reconciling them was our top source of bugs.
  • Every Challonge plan caps monthly API requests.4 Nothing pushes, so the bot polls, and polling alone eats the cap.
Before: capped API calls out, a sync that drifts back, and no support for how our tournaments run. Now: the engine computes the bracket straight against the database.
Before: Challonge ran the bracketbotChallongeAPIruns the bracketmonthly request capno Mantis 2.0botpolls results backdatabasecapped callsdriftsNow: our engine runs itbottournament engineruns the bracket, in-processdatabasefunction callno cap, no sync, our rules

Before: capped API calls out, a sync that drifts back, and no support for how our tournaments run. Now: the engine computes the bracket straight against the database.

Engine design

A tournament format is a procedure: given seeds and results, its rules determine the pairings, the bracket, and the standings. FIDE publishes Swiss pairing as exactly that5, and Mantis 2.0 is a formula over results. The bracket engine implements the procedures directly, as pure functions: tournament state is a function of results.

Why that's the right design:

  • Nothing can drift. There is no second copy of bracket state; every read derives from the results.
  • Corrections are free. Late reports, admin fixes, and drops are normal in a Discord-run scene: fix the result and everything downstream re-derives.
  • The industry's own interfaces agree. Reporting a match on start.gg or Challonge sends winner and score and reads back derived state6. The engine is that contract as a function.
  • A function can be replayed. That's what makes the verification below possible.
winnersplayer Aplayer CA wonplayer Aplayer Blosersplayer Cplayer Duntouchedadvance(matches, result)no databaseno networkno clockno randomnessOne result in.Two slots filled.Nothing else touched.

How the engine works

The bracket engine operates on one phase at a time. Three phase types cover everything the scene plays: Swiss, single elimination, double elimination.

Three functions cover a phase: generate the opening matches from seeds, advance the phase by one reported result, compute standings from the match list. A tournament is a chain of phases, and the app feeds one phase's standings in as the next phase's seeds. The engine only ever sees one phase.

Swisseveryone plays every roundR1R2R3nobody eliminated, records pairSingle elimlose once, outhalf the field leaves each roundDouble elimlosers get a second bracketlosses drop down· finals can resetMultiphasethe app chains phasesSwisstop cutone phase's standings seed the next

Single elimination

Lose once, out. Two things have to be right before a card is played:

  • Seeds placed so the top two can only meet in the final.7
  • When the field doesn't fill the bracket, the free wins go to the top seeds.

The engine settles both at generation, so the bracket opens already correct.

round 1round 2finalseed 1byeseed 4seed 5seed 2byeseed 3seed 6seed 14 or 5seed 23 or 6Two slots are empty, so those matches are byes: seeds 1 and 2 stand in round 2 before anyone plays.

Double elimination

Lose once and you drop to a second bracket; lose twice, out.8 Three things had to be right:

  • Where losers land. A player must not immediately rematch whoever just beat them, so each round's losers drop into the lower bracket by a fixed rotation.
  • Byes with no loser. A bye sends nobody down, so a lower match fed only by byes never gets anyone, and the player waiting on it waits forever. The engine marks those matches dead and hands out the bye. Until it did, 28 of the 63 player counts up to 64 could not finish.
  • The grand final. The lower-bracket survivor can force a reset match by winning once; the engine wires that as bracket structure, not a special case.
winners round 1winners round 21seed 1bye2seed 4seed 53seed 2bye4seed 3bye5seed 14 or 56seed 2seed 3match 7, the winners final, not shownlosers round 1losers round 28loser of 1loser of 2bye: one feeder was a bye, loser of 2 advances9loser of 3loser of 4dead: both feeders were byes10loser of 6winner of 811loser of 5winner of 9bye: loser of 5 advancesByes send no loser down, so nobody can ever arrive at match 9. The engine marks it dead,and match 11 becomes a bye instead of waiting forever for a player who does not exist.

Swiss pairing

Nobody is eliminated. Each round you play someone on your record; after a set number of rounds the standings decide.9 A round is only right if it holds for everyone at once:

  • Same record where possible.
  • Never the same opponent twice.
  • The bye goes to the lowest record without one.

Pairing players one at a time breaks those rules for whoever comes last, so the engine solves the whole field as one matching problem with the blossom algorithm10. When drops leave no clean round it still returns the closest one.5 The matching is an Edmonds blossom port.11

round 1round 2vsvsvsvsrematches: same pairs as round 1no rematch, same records pairedA1-0B0-1C0-1D1-0A1-0B0-1C0-1D1-0

Verification against Challonge

Challonge is the source of truth for bracket wiring: capture it once, then replay thousands of tournaments through both systems and diff every result.

  1. Capture once. 252 real Challonge bracket topologies, ~1,250 API calls.
  2. Replay offline. 4,440 differential playouts against an emulator built from the captured wiring. 0 unexplained mismatches.
  3. Live samples. 15 elimination tournaments compared result by result, 15 Swiss checked structurally. 0 divergences in champion or podium.
  4. Historical replay. 192 real brackets, 2019-2025, up to 266 players. 0 violations.
Every result goes into both. After each one, the open matches are diffed. 4,440 playouts, 0 unexplained mismatches.
one result sequence, fed to both1. seed 1 beats 42. seed 2 beats 33. seed 1 beats 2the engineseed 1seed 4seed 2seed 3seed 1seed 2seed 1championdiff after each result1 ✓ open matches match2 ✓ open matches match3 ✓ champion matchesChallongeseed 1seed 4seed 2seed 3seed 1seed 2seed 1champion

Every result goes into both. After each one, the open matches are diffed. 4,440 playouts, 0 unexplained mismatches.

Replay caught what tests missed: the double-bye deadlock above, a 2-player bracket that froze half the time, dead code that granted repeat byes. One known difference: at 9+ players, losers-bracket pairings diverge from Challonge (different rotation tables, both valid). Champion and podium always match.

The engine in production

The bot and the site call the same in-process API; no separate tournament service. It loads match rows, runs the bracket engine, writes rows back under a per-phase lock.

consumersDiscord botsignup, pairings, /reportgoatformat.combrackets, standings (SVG renderer)in-process, no HTTP hopAPI layerload rows, run engine, write rows, under a per-phase lockbracket enginepure functions, no I/O · Swiss (blossom) · single/double elimMantis 2.0 · bye propagation · 370 testsmatch rows: source of truthChallonge harnessreplays every resultdiffs the bracketsverifies

The open-source bracket engine

The bracket engine ships on its own, with a playground: github.com/nedhmn/bracket-engine. Pick a player count, report results, watch byes propagate. It pairs and advances one phase; phases, participants, and storage are the app's job. No runtime dependencies, no database required, typed end to end.

References

  1. About Goat Format: the format, the community, and the events it runs.

  2. Challonge: hosted brackets with a REST API; the scene ran on it from 2019 to 2025.

  3. Duelist Codex, "Tie-breakers": the Mantis tiebreaker system as Upper Deck ran it.

  4. Challonge API docs: plan-based monthly request caps; hobbyist tier is 10,000 requests per month.

  5. FIDE Handbook C.04.3, the Dutch System: Swiss pairing specified as a deterministic procedure over scores and pairing history. 2

  6. start.gg's reportBracketSet mutation and Challonge's match update both take winner and score, nothing else.

  7. Seeding on Wikipedia: the standard fold, 1 against 8, 4 against 5, 2 against 7, 3 against 6 in an eight-slot bracket. Challonge and start.gg place seeds the same way.

  8. Drarig29/brackets-manager.js: the double-elimination routing the engine ports. For 2^k players: k winners rounds, 2(k-1) losers rounds, drops placed by per-size rotation tables so nobody immediately rematches the player who beat them, and a grand final with no match, one match, or a reset.

  9. dambrisco/swiss-pairing: Swiss pairing on top of edmonds-blossom.

  10. Blossom algorithm: Edmonds' maximum matching over general graphs, 1965.

  11. Joris van Rantwijk's maximum matching: the reference implementation, via Matt Krick's edmonds-blossom JS port. Each pairing weighs minus (standing difference squared plus 1,000,000 times prior meetings); a bye edge costs the player's standing plus 1,000,000 per bye already taken.