Model and run tournament competitions in R.
bracketeer has a pipe-first API: stage types are verbs, you chain them to describe the structure, then drive it live with result entry. Downstream stages materialize automatically when their source completes.
library(bracketeer)
teams <- paste("Team", LETTERS[1:16])
tournament(teams) |>
swiss("open", rounds = 5) |>
single_elim("playoffs", take = top_n(8))The definition reads like a rulebook. The runtime feels like a scoreboard.
Installation
Install from CRAN:
install.packages("bracketeer")Or install the development version from GitHub:
# install.packages("pak")
pak::pak("bbtheo/bracketeer")Or try it right now without installing anything — open the World Cup 2026 simulation notebook in Google Colab:
A complete tournament
Four teams. A group stage, then a final for the top two. Definition to champion in one session.
teams <- c("Lions", "Bears", "Eagles", "Wolves")
trn <- tournament(teams) |>
round_robin("groups") |>
single_elim("grand_final", take = top_n(2))
trngroups opens immediately. grand_final is blocked until two teams qualify. Use matches() to get the schedule:
m <- matches(trn, "groups")
trn <- results(trn, "groups", data.frame(
match = m$match_id,
score1 = c(2, 1, 3, 1, 2, 1),
score2 = c(1, 2, 1, 0, 0, 0)
))
trnWhen the last group result lands, grand_final materializes automatically. No explicit advance call needed.
standings(trn, "groups") stage_id rank participant wins draws losses points score_diff sos
1 groups 1 Wolves 2 0 1 2 1 4
2 groups 2 Eagles 2 0 1 2 2 4
3 groups 3 Lions 1 0 2 1 -1 5
4 groups 4 Bears 1 0 2 1 -2 5
head_to_head
1 1
2 0
3 1
4 0Stage formats
Stage types are the verbs. Chain them onto tournament() to describe any competition structure. The from = argument defaults to the previous stage, so linear chains need no wiring at all.
Round-robin
Every participant plays every other participant. Standings accumulate points across all matches; groups = runs parallel group play within a single stage node.
Used in: Premier League, NBA regular season, FIFA World Cup group stage, Champions League league phase.
# World Cup style: 8 groups of 4, top 2 per group advance
teams_32 <- paste("Nation", sprintf("%02d", 1:32))
tournament(teams_32) |>
round_robin("groups", groups = 8) |>
single_elim("round_of_16", take = top_per_group(2))Swiss system
Participants are paired against others with the same current record across a fixed number of rounds. Nobody is eliminated during the stage — the final standings feed the next one.
Used in: chess olympiads, Magic: The Gathering GPs, Counter-Strike and VALORANT major group stages, Pokémon World Championships.
# Open qualifier → top 2 into a playoff final
tournament(teams) |>
swiss("open", rounds = 3) |>
single_elim("playoffs", take = top_n(2))Single elimination
One loss ends your tournament. The simplest bracket, and the most common knockout format. Use from = explicitly when two stages branch from the same source.
Used in: NCAA March Madness, Wimbledon, FIFA World Cup knockout rounds, NFL playoffs.
# Championship track and a consolation bracket from the same group stage
tournament(teams) |>
round_robin("groups") |>
single_elim("championship", from = "groups", take = top_n(2)) |>
single_elim("consolation", from = "groups", take = remaining())Double elimination
Two losses to be eliminated. Runs a winners bracket and a losers bracket in parallel — every entrant gets a second chance before they’re out.
Used in: StarCraft II WCS, VALORANT Champions, most fighting-game majors (EVO), Dota 2 The International.
tournament(teams) |>
double_elim("bracket")Two-leg knockout
Each tie is played home and away; the aggregate score over both legs decides who advances. Supports away_goals = TRUE for the classic away-goals rule.
Used in: UEFA Champions League knockout rounds, Copa Libertadores, Europa League.
# UCL style: 4 groups of 4, top 2 per group into two-leg knockouts
teams_16 <- paste("Club", sprintf("%02d", 1:16))
tournament(teams_16) |>
round_robin("groups", groups = 4) |>
two_leg("knockouts", take = top_per_group(2))All routing selectors — top_n, top_per_group, remaining, losers, slice_range, filter_by, and their _per_group variants — sit in take = and evaluate against the source stage’s standings at transition time.
The spec path
Define a blueprint without participants, validate it, then reuse it across different fields:
my_spec <- spec() |>
round_robin("groups") |>
single_elim("finals", from = "groups", take = top_n(2)) |>
single_elim("consolation", from = "groups", take = remaining())
validate(my_spec, n = 16) # errors loudly if routing is infeasible
trn2 <- build(my_spec, teams)
trn2Entering results
# One at a time
trn2 <- result(trn2, "groups", match = 1, score = c(2, 1))
# Batch: a data frame with columns match, score1, score2
more <- matches(trn2, "groups")
trn2 <- results(trn2, "groups", data.frame(
match = more$match_id,
score1 = rep(2L, nrow(more)),
score2 = rep(0L, nrow(more))
))score = c(home, away) — always a numeric vector. For best-of series, pass per-game scores and bracketeer sums them: score = c(1, 0, 1, 0, 1).
Manual advance (opt-in)
Auto-advance is the default. Pass auto_advance = FALSE to control each stage transition yourself:
trn_m <- tournament(teams, auto_advance = FALSE) |>
round_robin("groups") |>
single_elim("grand_final", take = top_n(2))
m <- matches(trn_m, "groups")
trn_m <- results(trn_m, "groups", data.frame(
match = m$match_id,
score1 = c(2, 1, 3, 1, 2, 1),
score2 = c(1, 2, 1, 0, 0, 0)
))
# Groups are complete but grand_final hasn't opened yet
stage_status(trn_m)
trn_m <- advance(trn_m, "groups")
stage_status(trn_m)API reference
Definition verbs
| Function | Purpose |
|---|---|
tournament(participants, auto_advance = TRUE) |
Create a live tournament |
spec() |
Create a reusable blueprint |
round_robin(id, ...) |
Add round-robin stage |
single_elim(id, ...) |
Add single-elimination stage |
double_elim(id, ...) |
Add double-elimination stage |
swiss(id, ...) |
Add Swiss-system stage |
two_leg(id, ...) |
Add two-leg knockout stage |
group_stage_knockout(id, ...) |
Add combined group+knockout stage |
Each verb accepts from = previous_stage() (default in linear chains) and take = (routing selector; default: all participants from source).
Routing selectors
| Selector | Picks |
|---|---|
top_n(n) |
Top n by overall standings |
bottom_n(n) |
Bottom n by overall standings |
slice_range(from, to) |
Positions from–to |
top_per_group(n) |
Top n from every group |
bottom_per_group(n) |
Bottom n from every group |
slice_per_group(from, to) |
Positions from–to within every group |
remaining() |
Not yet consumed by a prior transition |
losers() |
Eliminated participants |
filter_by(fn) |
Custom predicate on the standings data frame |
Runtime verbs
| Function | Purpose |
|---|---|
result(trn, stage, match, score) |
Enter one match result |
results(trn, stage, df) |
Batch result entry |
advance(trn, stage) |
Manually advance a completed stage |
teardown(trn, stage) |
Un-materialize a stage and all dependents |
Documentation
-
vignette("tournament-lifecycle")— Full API lifecycle walkthrough -
vignette("fifa-world-cup")— Group stage routing withtop_per_group() -
vignette("swiss-top-cut")— Swiss to single-elimination linear chain -
vignette("nhl-stanley-cup")— Best-of-7 single-elimination playoffs -
vignette("error-catalog")— Common errors and how to fix them
