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] <- 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).
#> 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).
#> 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.4The 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 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 straight seeding
cfb_playoff_seeds() implements the 2025 straight
seeding rule:
- Order all teams by committee rank (
rankings); unranked teams fall back behind ranked ones (and to win pct, SOV, SOS, and point differential whenrankings = NULL). - The 5 highest-ranked conference champions are guaranteed a spot (fewer if fewer champions exist).
- Fill the remaining spots with the best-ranked at-large teams.
- 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 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.
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)