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 <- read.csv(system.file("extdata", "toy_games.csv", package = "cfbseedR"))
teams <- read.csv(system.file("extdata", "toy_teams.csv", package = "cfbseedR"))
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).
#> 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).
#> DONE!
sim_elo$overall[, c("team", "wins", "playoff")]
#> # A tibble: 9 × 3
#>   team   wins playoff
#>   <chr> <dbl>   <dbl>
#> 1 A1      2.9     0.4
#> 2 A2      2.1     0.6
#> 3 A3      2.5     1  
#> 4 A4      0.6     0.1
#> 5 B1      3.6     0.6
#> 6 B2      2.1     0.7
#> 7 B3      1.2     0.1
#> 8 B4      0.8     0.1
#> 9 I1      1.2     0.4

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 <- read.csv(system.file("extdata", "toy_games.csv", package = "cfbseedR"))
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 straight seeding

cfb_playoff_seeds() implements the 2025 straight seeding rule:

  1. Order all teams by committee rank (rankings); unranked teams fall back behind ranked ones (and to win pct, SOV, SOS, and point differential when rankings = NULL).
  2. The 5 highest-ranked conference champions are guaranteed a spot (fewer if fewer champions exist).
  3. Fill the remaining spots with the best-ranked at-large teams.
  4. Seed the field strictly in ranking order - champions are not bumped up to the top-4 seeds (that was the pre-2025 rule).

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.

Real schedules

With cfbfastR installed, a real season plugs straight in:

# Not run: requires network access via cfbfastR
sched <- cfbfastR::load_cfb_schedules(2024)
games_2024 <- cfb_games_from_schedule(sched)
games_2024 <- games_2024[games_2024$game_type != "POST", ]
teams_2024 <- unique(data.frame(
  team = c(sched$home_team, sched$away_team),
  conference = c(sched$home_conference, sched$away_conference)
))
sim <- cfb_simulations(games_2024, teams_2024, simulations = 1000)