Tournament Brackets: Seeding, Double Elimination and Regional Separation
MediumAI-assisted coding · AI-Assisted Debugging
Bracket Service: Seeding, Double Elimination and Regional Separation
You've joined the tournament-tech team at an esports organizer. Their bracket service builds the brackets for every open qualifier and reports results as matches finish. Players have been complaining: the top two seeds meet in the semifinal, and 8-player events somehow get a whole extra round of byes. Your job is to fix the single-elimination brackets, then teach the service double elimination, then honor a new rule from the league office: players from the same region must not meet early.
Codebase
| File | What it does |
|---|---|
bracket/api.py | run(operations) entry point and the Dispatcher that routes each operation |
bracket/tournament.py | Tournament: validates requests, reports results, answers queries, simulate |
bracket/engine.py | Bracket: the match graph, advancing winners, the where location index |
bracket/seeding.py | Bracket size, seed order, and which entrant sits in which position |
bracket/queries.py | "Who plays next" and "path to the final" |
bracket/models.py | Entrant, Match, statuses, normalize_region |
bracket/errors.py | BracketError and every error code |
bracket/config.py | Entrant limits and match id prefixes |
tests/ | Existing unit and end-to-end tests (several currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh service and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised. An unknown operation name returns "error: unknown operation"; a wrong number of arguments (or an operation that is not a non-empty list starting with a string) returns "error: invalid arguments". Both are checked before anything else. Any operation other than create before a successful create returns "error: no tournament". A service holds exactly one tournament.
| Operation | Returns | Errors (checked in this order) |
|---|---|---|
create, entrants | {"size": S, "rounds": R, "byes": S - N} | already created, invalid arguments (entrants neither a list nor a generator object, or a malformed generator), too few entrants (N < 2), too many entrants (N > 1024), invalid arguments (a malformed row), duplicate entrant |
pairings | round 1 as a list of [a, b] name pairs in match order; null marks a bye | none |
match, match_id | {"id", "a", "b", "status", "winner"}; a/b/winner are names or null | unknown match |
report, match_id, winner | {"winner_to": id or null, "loser_to": id or null}: the matches the two entrants were routed to | unknown match, already decided, match not ready, not a participant |
walkover, match_id, absent | same as report; the other entrant wins | same as report |
next, name | [match_id, opponent or null] for the entrant's current match | unknown entrant, eliminated, no further matches (the champion) |
path, name | list of match ids the entrant must win, current match first | same as next |
simulate, mode | number of matches played | invalid arguments |
status | {"champion": name or null, "played": n, "walkovers": n} | none |
entrantsis a list of[name, region]rows in seed order: the first row is seed 1. A name is a non-empty string (case-sensitive, used as given); a region is a string ornull. Anything else makes the row malformed.- Large fields do not have to be written out: instead of a list,
entrantsmay be a generator object{"count": n, "prefix": "s", "regions": [...]}(prefixandregionsoptional). Entranti(1-based) is namedprefix + i(default prefix"s", sos1,s2, ...) and getsregions[(i - 1) % len(regions)](no region whenregionsis missing or empty). Seeexpand_entrantsinseeding.py; the entrant limits apply to the result. - A non-string
match_id,winner,absent,nameormodereturnsinvalid arguments. reportandwalkoveruse the same checks. A match that is already decided (including byes) isalready decided; one that does not yet have two entrants ismatch not ready; naming someone who is not playing in it isnot a participant. A walkover recordsabsentas the loser exactly like a played loss: the result routes the same way and only the status differs.simulateplays every match that can be played until none is left."favorites": the better seed (lower number) wins every match."upsets": the worse seed wins every match. Results do not depend on the order matches are played in. Any other mode isinvalid arguments.status.playedcounts matches with statusplayed,walkoversthose with statuswalkover; byes and skipped matches count as neither.
Bracket model (single elimination)
Entrants are seeded 1..N. The bracket has S positions, where S is the smallest power of two that is at least N (N = 8 gives S = 8) and R = log2(S) rounds. Positions are numbered 0..S-1 from the top.
Seed order. The seed at each position comes from this construction: start with [1]. To double a list of length m into one of length 2m, replace every seed x (left to right) by the two seeds x, 2m + 1 - x.
- size 2:
[1, 2] - size 4:
[1, 4, 2, 3] - size 8:
[1, 8, 4, 5, 2, 7, 3, 6] - size 16:
[1, 16, 8, 9, 4, 13, 5, 12, 2, 15, 7, 10, 3, 14, 6, 11]
Round 1 match j (1-based) is played between the entrants at positions 2j-2 and 2j-1.
Byes. Seeds above N do not exist, so their positions are empty. A seed that would face an empty position has a bye: its round 1 match is decided immediately with status bye, nobody is eliminated, and the entrant is already waiting in round 2. Because of the seed order, the byes always go to the top S - N seeds. pairings shows an empty position as null (in the second place of the pair).
Matches. The id of match j of round r is W{r}-{j} (W1-1, W2-3, ...). The winner of W{r}-{j} moves to W{r+1}-{ceil(j/2)}, into slot a if j is odd and slot b if j is even. The winner of the last round's only match is the champion. A match has status pending (an entrant is still missing), ready (both entrants known, not decided), played, walkover, bye or skipped. In match and next, null means no entrant is known (yet).
Queries. next returns the entrant's current undecided match and the opponent, null while that opponent is still unknown. An entrant who lost is eliminated; the champion has no further matches. path lists the matches the entrant has to win to become champion, starting with the current one: after a bye, round 1 is not on the path.
Part 1: Fix single-elimination brackets
Goal: make create, pairings, match, report, walkover, next, path, simulate and status behave exactly as described above. The service currently produces brackets with the wrong shape: read tests/ for failing examples, then find the causes in seeding.py and engine.py.
- Round 1 must follow the seed-order construction above for every size.
- The bracket size and the number of byes must follow the rules above for every N.
- Bye holders must land in the correct round 2 slot, so that when the sibling match is reported nobody is overwritten.
Example (6 entrants named s1..s6, no regions):
[["create", [["s1", null], ["s2", null], ["s3", null], ["s4", null], ["s5", null], ["s6", null]]],
["pairings"],
["match", "W2-1"],
["report", "W1-2", "s5"],
["match", "W2-1"],
["next", "s5"],
["path", "s1"]]returns
[{"size": 8, "rounds": 3, "byes": 2},
[["s1", null], ["s4", "s5"], ["s2", null], ["s3", "s6"]],
{"id": "W2-1", "a": "s1", "b": null, "status": "pending", "winner": null},
{"winner_to": "W2-1", "loser_to": null},
{"id": "W2-1", "a": "s1", "b": "s5", "status": "ready", "winner": null},
["W2-1", "s1"],
["W2-1", "W3-1"]]Part 2: Double elimination
Goal: create accepts an optional second argument, an options object. {"format": "double"} builds a double-elimination bracket: losing once drops an entrant into a losers' bracket, losing twice eliminates them, and a grand final decides the champion. The default ("single", or no options) is exactly Part 1. Part 1's rules for seeding, byes, queries and errors stay in force for the winners' bracket.
Options error handling (checked after already created and the invalid arguments check on the entrants list): an options value that is not an object is invalid arguments; an unknown key, a format other than "single" or "double" is invalid option. Then the entrant count checks run, with the minimum raised to 3 entrants for a double bracket (too few entrants).
Let S, R be as before. Match ids are W{r}-{j} for the winners' bracket (as in Part 1, with the final W{R}-1 now feeding the grand final), L{k}-{j} for the losers' bracket, GF1 and GF2.
- Losers' bracket shape. It has
2R - 2rounds. RoundkhasS >> (ceil(k/2) + 1)matches (for S = 16: 4, 4, 2, 2, 1, 1). - Round 1 drop. Losers of
W1-jgo toL1-ceil(j/2): slotaifjis odd, slotbifjis even. - Later drops. For
r >= 2the loser ofW{r}-jgoes to slotbofL{2r-2}-c, wheren = S >> randc = jifris odd,c = n + 1 - jifris even (the "cross" that keeps entrants from meeting again right away). - Winners within the losers' bracket. The winner of
L{k}-jgoes to:L{k+1}-j, slota, whenkis odd;L{k+1}-ceil(j/2), slotaifjis odd andbifjis even, whenkis even andk < 2R - 2;GF1, slotb, whenk = 2R - 2. The loser of any losers' bracket match is eliminated. - Grand final.
GF1isW{R}-1's winner (slota) againstL{2R-2}-1's winner (slotb). If slotawins, they are champion, the other entrant is eliminated,GF2becomesskipped, and the result is{"winner_to": null, "loser_to": null}. If slotbwins (the bracket reset), both entrants go toGF2in the same slots and the result is{"winner_to": "GF2", "loser_to": "GF2"}; the winner ofGF2is champion and the other is eliminated. - Byes in the losers' bracket. A slot fed by a bye match (nobody lost it) is *empty forever*. A match with both slots known and exactly one empty is a
bye: the other entrant is its winner and moves on at once, and its loser is "nobody", which fills the loser slot of that match (if it has one) as another empty slot. A match whose slots are both empty is also abye; nobody wins it and it sends empty slots on. This can cascade through several matches the moment the bracket is created or a loser arrives. A match with one empty slot and one slot still unknown stayspendinguntil that slot is filled. reportandwalkoverreturn where the winner and the loser were routed, as ids; the loser of a losers' bracket match, and the final results above, givenull. Routed ids are returned even when the match they point to resolves itself as a bye.nextandpathwork across both brackets.pathlists the matches the entrant must win, starting with the current one and following winners only, ending withGF1(GF2is never listed); matches that are or will be byes (an empty slot) are left out. Awalkoveris a loss forabsent: they drop or are eliminated exactly as if they had lost a played match.simulateplaysGF2too if it is ready whensimulateruns. A bracket without a reset hasN - 1playable matches (single) or2N - 2(double). The crossing in rule 3 is not a rematch guarantee: two entrants can meet again (always possible in the grand final, and in small brackets).
Example (4 entrants, double elimination):
[["create", [["s1", null], ["s2", null], ["s3", null], ["s4", null]], {"format": "double"}],
["report", "W1-1", "s4"],
["report", "W1-2", "s2"],
["report", "L1-1", "s1"],
["report", "W2-1", "s2"],
["next", "s4"],
["report", "L2-1", "s1"],
["path", "s1"],
["report", "GF1", "s1"],
["match", "GF2"],
["report", "GF2", "s2"],
["status"]]returns
[{"size": 4, "rounds": 2, "byes": 0},
{"winner_to": "W2-1", "loser_to": "L1-1"},
{"winner_to": "W2-1", "loser_to": "L1-1"},
{"winner_to": "L2-1", "loser_to": null},
{"winner_to": "GF1", "loser_to": "L2-1"},
["L2-1", "s1"],
{"winner_to": "GF1", "loser_to": null},
["GF1"],
{"winner_to": "GF2", "loser_to": "GF2"},
{"id": "GF2", "a": "s2", "b": "s1", "status": "ready", "winner": null},
{"winner_to": null, "loser_to": null},
{"champion": "s2", "played": 7, "walkovers": 0}]Part 3: Regional separation
Goal: the league office wants entrants from the same region kept apart in the opening rounds. create options gain "separation": k (an integer >= 0, default 0, a bool is not an integer; anything else is invalid option). With k = 0 nothing changes: positions are exactly those of Part 1. With k >= 1, the service rearranges the positions with the procedure below before building the bracket (single or double).
Two entrants *conflict* when they have the same region (after normalize_region; entrants without a region never conflict) and sit in the same block: positions p and q share a block when p >> k' == q >> k' with k' = min(k, R). Sharing a block means they could meet in one of the first k' rounds. (For k = 1 that is "paired in round 1".)
Procedure. Start from the Part 1 positions. The bye holders (the top S - N seeds) are pinned: they never move. Go through seeds s in ascending order, skipping pinned seeds. Let E be the entrant of seed s, at position p. Only entrants with a lower seed number than s count when looking for conflicts (their positions are final).
- If
Ehas no region, or no lower-seeded entrant of its region sharesE's block, move on. - Otherwise try the candidates
Gin ascending seed order, over seedss + 1 .. N(pinned seeds are never candidates).Gis at positionq. The swap is accepted for the firstGfor which both hold: no lower-seeded (thans) entrant ofE's region shares the block ofq, and no lower-seeded entrant ofG's region (ifGhas a region) shares the block ofp. - On accepting,
EandGexchange positions. If no candidate qualifies,Estays where it is (the conflict is unavoidable) and the procedure moves on.
Positions of seeds s + 1 .. are not final until they are processed themselves, so a swapped-in G can move again when its turn comes. Bye holders keep their byes and their positions, and the bracket model (byes, round 2 placement, double elimination) is then built from the final positions. Seed numbers do not change, simulate still compares seeds, and create returns the same sizes. Part 3 only moves entrants; all rules of Parts 1 and 2 apply to the result.
The service must stay fast: events of up to 1,024 entrants with many regions are created repeatedly.
Example (8 entrants, regions given, separation 1 and 2):
[["create", [["Ash", "EU"], ["Bo", "NA"], ["Cy", "EU"], ["Dee", "NA"], ["Eli", "EU"], ["Fay", "KR"], ["Gus", "NA"], ["Hal", "KR"]], {"separation": 1}],
["pairings"]]Without separation round 1 is Ash-Hal, Dee-Eli, Bo-Gus, Cy-Fay: Bo and Gus (both NA) are paired. Gus (seed 7) is the first conflict; Hal (seed 8) is the first candidate who fits, so they swap:
[{"size": 8, "rounds": 3, "byes": 0},
[["Ash", "Gus"], ["Dee", "Eli"], ["Bo", "Hal"], ["Cy", "Fay"]]]With "separation": 2 (blocks of four positions) the arrangement stays the Part 1 one: Eli (EU) shares a block with Ash and no worse seed fits, so the conflict is unavoidable.
Notes
- Part 1's behavior must keep working after Parts 2 and 3; hidden tests re-check it.
- Run
tests/however you like (they only useassert); hidden tests callrunwith operation lists. - Python 3 standard library only.
Example 1
- Input:
- [["create",[["s1",null],["s2",null],["s3",null],["s4",null],["s5",null],["s6",null]]],["pairings"],["report","W1-2","s5"],["next","s5"],["path","s1"]]
- Output:
- [{"size":8,"rounds":3,"byes":2},[["s1",null],["s4","s5"],["s2",null],["s3","s6"]],{"winner_to":"W2-1","loser_to":null},["W2-1","s1"],["W2-1","W3-1"]]
- Explanation
- Six entrants use an 8-position bracket, so seeds 1 and 2 get byes and are already waiting in round 2. s5 beats s4 and moves to W2-1 to face s1.
Example 2
- Input:
- [["create",[["s1",null],["s2",null],["s3",null],["s4",null]],{"format":"double"}],["report","W1-1","s4"],["report","W1-2","s2"],["next","s1"]]
- Output:
- [{"size":4,"rounds":2,"byes":0},{"winner_to":"W2-1","loser_to":"L1-1"},{"winner_to":"W2-1","loser_to":"L1-1"},["L1-1","s3"]]
- Explanation
- Both round-1 losers drop into L1-1, so s1 (upset by s4) plays s3 in the losers' bracket.
Example 3
- Input:
- [["create",[["Ash","EU"],["Bo","NA"],["Cy","EU"],["Dee","NA"],["Eli","EU"],["Fay","KR"],["Gus","NA"],["Hal","KR"]],{"separation":1}],["pairings"]]
- Output:
- [{"size":8,"rounds":3,"byes":0},[["Ash","Gus"],["Dee","Eli"],["Bo","Hal"],["Cy","Fay"]]]
- Explanation
- Without separation Bo and Gus (both NA) would meet in round 1. Gus is the first conflicted seed and swaps with Hal, the first worse seed who fits.
Guidelines
- Python 3 standard library only
- Results are deterministic: the same operations always give the same output, and entrants are only ever compared by seed number
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Brackets hold up to 1,024 entrants; Part 3 inputs include events of 512 and 1,000 entrants with many regions