This article goes deeper than the Getting
started walkthrough: writing a custom compute_results
function, controlling the tiebreaker cascade with
tiebreaker_depth, and how the 12-team CFP straight-seeding
rules are applied. Everything runs offline on the bundled toy
season.
library(cfbseedR)
games <- cfb_games_example
teams <- cfb_teams_example
games$result[games$week >= 3] <- NACustom compute_results functions
cfb_simulations() calls
compute_results(teams, games, week_num, ...) once per
remaining week. The contract (identical to nflseedR’s):
- return
list(teams = teams, games = games), - fill
resultfor exactly the games withweek == week_num & is.na(result), - never modify any other result, remove rows/columns from
games, or produce a tie (result == 0) outside the regular season.
Here is a deliberately simple generator: every missing result is a home win by 3 (postseason-safe since 3 is never a tie):
home_field_rules <- function(teams, games, week_num, ...) {
fill <- games$week == week_num & is.na(games$result)
games$result[fill] <- 3
list(teams = teams, games = games)
}simulations_verify_fct() checks a custom function
against the full contract before you burn simulation time on it:
simulations_verify_fct(home_field_rules)Then plug it in:
set.seed(1)
sim <- cfb_simulations(
games, teams,
compute_results = home_field_rules,
simulations = 10, playoff_seeds = 4
)
#> Start simulation of 10 seasons (4 weeks to simulate) in 8 chunks.
#> ℹ Chunks run sequentially. Set a parallel `future::plan()`, e.g.
#> `future::plan("multisession")`, to use more cores.
#> DONE!
sim$overall[, c("team", "wins", "conf_champ", "playoff")]
#> # A tibble: 9 × 4
#> team wins conf_champ playoff
#> <chr> <dbl> <dbl> <dbl>
#> 1 A1 4 1 1
#> 2 A2 2 0 1
#> 3 A3 2 0 1
#> 4 A4 0 0 0
#> 5 B1 4 1 1
#> 6 B2 2 0 0
#> 7 B3 1 0 0
#> 8 B4 1 0 0
#> 9 I1 1 0 0Anything extra your generator needs (a ratings vector, market lines,
an externally trained model) can be passed through the ...
of cfb_simulations(). The default generator accepts initial
ELO ratings that way:
elo <- setNames(seq(1700, 1300, length.out = nrow(teams)), teams$team)
set.seed(2)
sim_elo <- cfb_simulations(
games, teams,
simulations = 10, playoff_seeds = 4,
elo = elo
)
#> Start simulation of 10 seasons (4 weeks to simulate) in 8 chunks.
#> ℹ Chunks run sequentially. Set a parallel `future::plan()`, e.g.
#> `future::plan("multisession")`, to use more cores.
#> DONE!
sim_elo$overall[, c("team", "wins", "playoff")]
#> # A tibble: 9 × 3
#> team wins playoff
#> <chr> <dbl> <dbl>
#> 1 A1 3.3 0.9
#> 2 A2 2 0.4
#> 3 A3 2.4 1
#> 4 A4 0.9 0
#> 5 B1 3.5 0.9
#> 6 B2 1.8 0.2
#> 7 B3 1.1 0.1
#> 8 B4 1.3 0.3
#> 9 I1 0.7 0.2The tiebreaker_depth ladder
Conference ranks are seeded by conference win percentage; ties are broken by a cascade whose depth you control:
tiebreaker_depth |
Cascade applied before the coin flip |
|---|---|
"RANDOM" |
Nothing - coin flip immediately. |
"PRE-SOV" |
Head-to-head, then common conference opponents. |
"SOS" (default) |
"PRE-SOV" + strength of victory, then strength of
schedule. |
"POINTS" |
"SOS" + conference point differential. |
verbosity = "MAX" logs every step the cascade takes,
which is the easiest way to understand (and trust) a rank:
played <- cfb_games_example
standings <- cfb_standings(played, teams,
tiebreaker_depth = "POINTS", verbosity = "MAX")
#> Initiate standings & tiebreaking data
#> Compute conference ranks
#> Breaking tie of "A1", "A2", and "A3" in "Alpha" (sim 2024).
standings[, c("team", "conference", "conf_pct", "sov", "sos", "conf_rank")]
#> # A tibble: 9 × 6
#> team conference conf_pct sov sos conf_rank
#> <chr> <chr> <dbl> <dbl> <dbl> <int>
#> 1 A1 Alpha 0.667 0.333 0.444 1
#> 2 A2 Alpha 0.667 0.333 0.444 2
#> 3 A3 Alpha 0.667 0.333 0.444 3
#> 4 A4 Alpha 0 0 0.667 4
#> 5 B1 Beta 1 0.333 0.333 1
#> 6 B2 Beta 0.667 0.167 0.444 2
#> 7 B3 Beta 0.333 0 0.556 3
#> 8 B4 Beta 0 0 0.667 4
#> 9 I1 FBS Independents 0 0 0 NAThe conference-scoped sov/sos ruling
All cascade quantities - head-to-head, common opponents,
sov, sos, and point differential - are
computed over regular-season conference games only, so
conference ranks depend only on conference play. Concretely:
-
sov= beaten conference opponents’ conference wins divided by their conference games (strength of victory over conference victories), -
sos= all conference opponents’ conference wins divided by their conference games (strength of schedule over conference opponents), - independents have no conference games, so both are
0.0.
This is a documented simplification: real CFB tiebreakers are conference-specific, and a full-schedule SOS would let non-conference results leak into conference seeding.
Official per-conference tiebreakers
The generic cascade above is the fallback used for conferences
without a registered procedure. The SEC, Big Ten, Big 12, ACC,
and MAC instead use their official 2024+ tiebreaker
procedures (ported from the same registry as sdv-py’s
cfb_standings.py, so both engines agree on a shared
cross-language fixture). These add rungs the generic cascade doesn’t
have - record vs. common opponents by order of finish, a pooled
conference opponents’ win percentage, the SEC’s capped relative scoring
margin (points scored capped at 42 / allowed at 48 per game), the Big
12’s total_wins with its FCS-or-lower win cap, and an
external analytics rating - and they always run to completion
(tiebreaker_depth only gates the generic fallback). Two
optional inputs feed these rungs when available:
home_points/away_points on games
(the SEC cap) and a division column on teams
(the Big 12 FCS cap);
tiebreaker_data = list(analytics_ratings = ratings)
supplies the external rating rung. Any rung whose input is missing is
skipped, and the skip is recorded in
attr(standings, "tiebreak_notes"):
sec_teams <- data.frame(
team = c("A", "B", "C"), conference = "SEC", division = "FBS"
)
sec_games <- data.frame(
sim = 2024, week = 1:3, game_type = "REG",
home_team = c("A", "B", "C"), away_team = c("B", "C", "A"),
result = c(50, 3, 3), neutral = 0,
home_points = c(50, 20, 20), away_points = c(0, 17, 17)
)
sec_standings <- cfb_standings(sec_games, sec_teams,
tiebreaker_depth = "POINTS", verbosity = "NONE")
sec_standings[, c("team", "conf_rank")]
#> # A tibble: 3 × 2
#> team conf_rank
#> <chr> <int>
#> 1 A 1
#> 2 B 2
#> 3 C 3
attr(sec_standings, "tiebreak_notes")
#> character(0)CFP-12 seeding and the autobid policy
cfb_playoff_seeds() always uses straight
seeding - the field is ordered strictly by committee rank, and
champions are never bumped up to the top-4 seeds (that was the pre-2025
rule). What changes between seasons is who is guaranteed a
place, which is the autobid argument:
-
autobid = "2026"(the default, today’s rule): the ACC, Big 12, Big Ten and SEC champions are in regardless of their ranking, the highest-ranked Group-of-6 team is in whether or not it won its conference, and Notre Dame is in when ranked inside the field. -
autobid = "2025": the 5 highest-ranked conference champions are guaranteed (fewer if fewer champions exist) - the 2024-2025 rule.
Under either policy the remaining places go to the best-ranked at-large teams, and the whole field is then seeded in rank order.
A synthetic 16-team example with 6 conference champions shows the
guarantee and the straight ordering; champion F1 (ranked
14th) displaces the lowest at-large team but keeps its rank-order
seed:
st <- data.frame(
sim = 1,
team = sprintf("%s1", LETTERS[1:16]),
conference = c(LETTERS[1:6], LETTERS[1:10]),
conf_champ = c(rep(TRUE, 6), rep(FALSE, 10)),
win_pct = seq(1, 0.4, length.out = 16),
sov = 0.5, sos = 0.5, pd = 100
)
rankings <- data.frame(
team = st$team,
rank = c(1, 2, 3, 5, 8, 14, 4, 6, 7, 9, 10, 11, 12, 13, 15, 16)
)
seeded <- cfb_playoff_seeds(st, rankings = rankings, playoff_seeds = 12)
seeded[!is.na(seeded$seed), c("team", "conf_champ", "seed")] |>
(\(x) x[order(x$seed), ])()
#> # A tibble: 12 × 3
#> team conf_champ seed
#> <chr> <lgl> <int>
#> 1 A1 TRUE 1
#> 2 B1 TRUE 2
#> 3 C1 TRUE 3
#> 4 G1 FALSE 4
#> 5 D1 TRUE 5
#> 6 H1 FALSE 6
#> 7 I1 FALSE 7
#> 8 E1 TRUE 8
#> 9 J1 FALSE 9
#> 10 K1 FALSE 10
#> 11 L1 FALSE 11
#> 12 M1 FALSE 12In cfb_simulations(), the bracket generated from these
seeds is the fixed CFP-12 bracket (first round hosted by the higher
seed, later rounds neutral-site, no reseeding), and committee
rankings are held static across simulations.
Running simulations in parallel
Simulations are split into chunks (default 8) and
dispatched with furrr, so a parallel plan spreads them across cores:
future::plan("multisession")
sim <- cfb_simulations(games, teams, simulations = 10000, chunks = 8)
future::plan("sequential")Progress reporting comes from progressr - switch it on once and every call reports:
progressr::handlers(global = TRUE)A given seed reproduces exactly for a fixed chunks, and
a parallel plan gives identical results to a sequential one. The RNG
stream does depend on the chunking, though, so the same seed with a
different chunks gives different (equally valid)
simulations.
Summarising a simulation, and checking it
summary() renders the overall table as a gt
table grouped by conference, with probabilities formatted by
fmt_pct_special() so a long-shot reads <1%
rather than a flat 0% and a near-lock
>99.9% rather than a false 100%. Here it is
on a real 1,000-season run:
set.seed(2026)
sim <- cfb_simulations(games, teams, simulations = 1000, playoff_seeds = 4,
chunks = 4, verbosity = "NONE")
summary(sim)| Simulating the 2024 college football season | |||||
| summary of 1K simulations using cfbseedR | |||||
| AVG. WINS |
Win CONF |
Make CFP |
No.1 Seed |
Win NATTY |
|
|---|---|---|---|---|---|
| Alpha | |||||
| A3 | 2.6 | 0% | 98% | 52% | 19% |
| A1 | 2.5 | 57% | 42% | 3% | 6% |
| A2 | 1.8 | 43% | 33% | 7% | 4% |
| A4 | 1.1 | 0% | 13% | 0% | 6% |
| Beta | |||||
| B1 | 3.4 | 38% | 67% | 22% | 14% |
| B2 | 2.3 | 62% | 60% | 5% | 17% |
| B3 | 1.5 | 0% | 53% | 2% | 23% |
| B4 | 0.7 | 0% | 2% | 0% | <1% |
| FBS Independents | |||||
| I1 | 1.0 | 0% | 32% | 9% | 11% |
Before trusting any of those numbers, check that the simulation is internally consistent. Three identities have to hold by construction, and they are cheap to assert:
checks <- data.frame(
Check = c("playoff probabilities sum to the seed count",
"national-title probabilities sum to one champion",
"conference titles sum to one per conference"),
Expected = c(sim$sim_params$playoff_seeds, 1, 2),
Observed = round(c(sum(sim$overall$playoff),
sum(sim$overall$won_natty),
sum(sim$overall$conf_champ)), 3)
)
checks$OK <- checks$Expected == checks$Observed
checks
#> Check Expected Observed OK
#> 1 playoff probabilities sum to the seed count 4 4 TRUE
#> 2 national-title probabilities sum to one champion 1 1 TRUE
#> 3 conference titles sum to one per conference 2 2 TRUEAll three hold exactly on this run. When one drifts it is nearly
always a data problem rather than a simulation problem: a team appearing
in games but missing from teams, a conference
with no CONF_CHAMP fixture, or a custom
compute_results that left some scheduled results as
NA.
The per-team win distribution is the other output worth eyeballing, since it exposes the shape of a season rather than just its mean:
tw <- sim$team_wins[sim$team_wins$team %in% c("A1", "B1"), ]
tw <- tw[tw$wins %in% c(1, 2, 3, 4), c("team", "wins", "over_prob")]
tw
#> # A tibble: 8 × 3
#> team wins over_prob
#> <chr> <dbl> <dbl>
#> 1 A1 1 0.831
#> 2 A1 2 0.483
#> 3 A1 3 0.168
#> 4 A1 4 0
#> 5 B1 1 1
#> 6 B1 2 0.759
#> 7 B1 3 0.452
#> 8 B1 4 0.152Real schedules
With cfbfastR installed, a real season plugs straight in:
# Not run: requires network access via cfbfastR
sched <- cfbfastR::load_cfb_schedules(2026)
real_games <- cfb_games_from_schedule(sched)
real_games <- real_games[real_games$game_type != "POST", ]
real_teams <- unique(data.frame(
team = c(sched$home_team, sched$away_team),
conference = c(sched$home_conference, sched$away_conference)
))
future::plan("multisession")
sim <- cfb_simulations(real_games, real_teams, simulations = 10000, chunks = 8)
future::plan("sequential")
summary(sim)The same three identities apply to a real season and are worth asserting there too — with 130-odd FBS teams and a schedule pulled from a live API, a missing conference label or an unplayed fixture is far likelier than in the toy data, and the sums will say so immediately.
Because the tiebreaker registry is season-scoped, feeding a
season column of 2024 or 2025 ranks those seasons under the
rules that were in force at the time, while
autobid = "2025" / "2026" selects the CFP era
independently. The tiebreakers
cookbook works through what changes when you flip each of them.
Our Authors
Adapted from nflseedR (they’re awesome)
cfbseedR is a college-football adaptation of nflseedR (MIT). The standings-to-seeds
architecture, the week-loop simulator with a pluggable
compute_results, and the ELO default generator are all
theirs.
Citation
To cite cfbseedR in publications, use:
Saiem Gilani (2026). cfbseedR: The SportsDataverse's R Package to Simulate
and Evaluate College Football Seasons.
https://cfbseedR.sportsdataverse.org/
citation("cfbseedR")If you use the season-simulation methodology, please also cite nflseedR.
Related SportsDataverse packages
-
cfbfastR
— college football play-by-play, schedules and rosters; feeds
cfb_games_from_schedule() - cfb4th — fourth-down decision modeling
- cfbplotR — team logos and plotting helpers for ggplot2
- recruitR — recruiting data
- sportsdataverse-R — the meta-package
-
sportsdataverse-py
— the Python mirror, whose
cfb_standingsshares this package’s tiebreaker rulings
A printable cheat sheet (PDF) covers cfbplotR, cfb4th and cfbseedR together.