Skip to contents

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] <- NA

Custom 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 result for exactly the games with week == 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       0

Anything 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.2

The 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            NA

The 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         12

In 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 TRUE

All 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.152

Real 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.

A printable cheat sheet (PDF) covers cfbplotR, cfb4th and cfbseedR together.