Skip to contents

cfbseedR implements each FBS conference’s official championship-game tiebreaker procedure as season-scoped epochs, and the CFP’s season-keyed automatic-qualifier policies. That combination lets you ask the question the rulebook keeps forcing on college football: would last year’s outcome still hold under this year’s rules?

This cookbook works through three time horizons — a settled season re-examined, the present field, and a plausible future one — and reports what actually comes out.

A small helper so every table below renders the same way:

show <- function(x, title, subtitle = NULL) {
  gt::gt(x) |>
    gt::tab_header(title = title, subtitle = subtitle) |>
    gt::tab_options(
      table.font.size = gt::px(13),
      column_labels.font.weight = "bold",
      data_row.padding = gt::px(4)
    )
}

On the data below. The team names and records are constructed to isolate one rule difference at a time — they are not transcribed from a real box score. The rules are real, taken from the conferences’ and the CFP’s published policies. Every table is the package’s actual output, and the last section shows how to point the same code at a real season through cfbfastR.

The rules, at a glance (2026 season)

rules <- data.frame(
  Conference = c("SEC", "Big Ten", "Big 12", "ACC (2026+)", "American",
                 "Conference USA", "MAC", "Mountain West", "Sun Belt",
                 "Pac-12 (re-formed)"),
  Format = c(rep("single table", 8), "two divisions", "single table"),
  `After win pct` = c(
    "h2h -> common -> descent -> opp win% -> capped margin (42/48) -> draw",
    "h2h -> common -> descent -> opp win% -> analytics -> draw",
    "h2h -> common -> descent -> opp win% -> capped wins -> analytics -> draw",
    "candidate pool (wins OR losses) -> h2h -> SportSource SSR -> draw",
    "h2h -> CFP-ranked final week -> metric composite -> common -> draw",
    "h2h -> CFP-ranked final week -> composite -> APR -> draw",
    "h2h -> common opponents -> SportSource rating -> draw",
    "metric composite -> h2h -> draw",
    "h2h -> div win% -> descent -> common -> CFP clause -> composite -> draw",
    "no published procedure: generic fallback"
  ),
  check.names = FALSE
)
show(rules, "Registered tiebreaker procedures",
     "Unregistered conferences use the documented generic cascade")
Registered tiebreaker procedures
Unregistered conferences use the documented generic cascade
Conference Format After win pct
SEC single table h2h -> common -> descent -> opp win% -> capped margin (42/48) -> draw
Big Ten single table h2h -> common -> descent -> opp win% -> analytics -> draw
Big 12 single table h2h -> common -> descent -> opp win% -> capped wins -> analytics -> draw
ACC (2026+) single table candidate pool (wins OR losses) -> h2h -> SportSource SSR -> draw
American single table h2h -> CFP-ranked final week -> metric composite -> common -> draw
Conference USA single table h2h -> CFP-ranked final week -> composite -> APR -> draw
MAC single table h2h -> common opponents -> SportSource rating -> draw
Mountain West single table metric composite -> h2h -> draw
Sun Belt two divisions h2h -> div win% -> descent -> common -> CFP clause -> composite -> draw
Pac-12 (re-formed) single table no published procedure: generic fallback

PAST — re-examining a settled tie

The ACC replaced its entire tiebreaker in July 2026 for the move to a nine-game schedule. The new policy is short — head-to-head, then the SportSource Team Success Ranking, then a draw — but it adds a candidate pool rule: a team that played a different number of conference games joins the tie if it matches the leaders’ win count or loss count.

That pool rule is the part that rewrites history. Here is one league’s season ranked twice, from identical games — once as a 2025 season, once as 2026:

acc_teams <- data.frame(
  team = c("Ashwood", "Brightwater", "Cedarcrest", "Dunmore", "Eastvale",
           "Fairmont", "Glenrock", "Harborview", "Ironside"),
  conference = "ACC"
)
mk <- function(season, w, h, a, r) {
  data.frame(season = season, week = w, game_type = "REG",
             home_team = h, away_team = a, result = r)
}
acc_games <- function(season) rbind(
  mk(season, 1, "Ashwood", "Eastvale", 10),     mk(season, 2, "Ashwood", "Fairmont", 7),
  mk(season, 3, "Ashwood", "Glenrock", 3),      mk(season, 4, "Harborview", "Ashwood", 6),
  mk(season, 1, "Brightwater", "Glenrock", 14), mk(season, 2, "Brightwater", "Harborview", 21),
  mk(season, 3, "Brightwater", "Ironside", 3),  mk(season, 4, "Dunmore", "Brightwater", 4),
  mk(season, 1, "Cedarcrest", "Ironside", 17),  mk(season, 2, "Cedarcrest", "Dunmore", 9),
  mk(season, 3, "Cedarcrest", "Eastvale", 6),   mk(season, 4, "Fairmont", "Cedarcrest", 2),
  mk(season, 5, "Glenrock", "Cedarcrest", 1),   mk(season, 5, "Dunmore", "Eastvale", 8),
  mk(season, 6, "Fairmont", "Glenrock", 5),     mk(season, 6, "Harborview", "Ironside", 12),
  mk(season, 7, "Eastvale", "Ironside", 4),     mk(season, 7, "Dunmore", "Fairmont", 6)
)

# The conference's published metric. Brightwater rates best; Cedarcrest
# rates above Ashwood.
ssr <- data.frame(
  team = acc_teams$team,
  rating = c(88, 93, 90, 70, 60, 65, 62, 72, 55)
)

rank_season <- function(yr) {
  st <- cfb_standings(acc_games(yr), acc_teams, verbosity = "NONE",
                      tiebreaker_data = list(analytics_ratings = ssr))
  st <- st[order(st$conf_rank), ]
  data.frame(
    Rank = st$conf_rank,
    Team = st$team,
    `Conf W-L` = paste0(st$conf_wins, "-", st$conf_losses),
    `Win pct` = round(st$conf_pct, 3),
    check.names = FALSE
  )[1:5, ]
}
show(rank_season(2025), "Ranked under the pre-2026 ACC cascade",
     "The championship game is the top two rows")
Ranked under the pre-2026 ACC cascade
The championship game is the top two rows
Rank Team Conf W-L Win pct
1 Dunmore 3-1 0.750
2 Brightwater 3-1 0.750
3 Ashwood 3-1 0.750
4 Harborview 2-1 0.667
5 Cedarcrest 3-2 0.600
show(rank_season(2026), "The same games under the 2026 ACC policy",
     "The championship game is the top two rows")
The same games under the 2026 ACC policy
The championship game is the top two rows
Rank Team Conf W-L Win pct
1 Brightwater 3-1 0.750
2 Cedarcrest 3-2 0.600
3 Ashwood 3-1 0.750
4 Harborview 2-1 0.667
5 Dunmore 3-1 0.750

What changed, and why. Three teams tie at .750 — Ashwood, Brightwater and Dunmore. Under the pre-2026 cascade the head-to-head chain seats Dunmore and Brightwater. Under the 2026 policy the matchup becomes Brightwater and Cedarcrest — and Cedarcrest is 3-2, a worse win percentage than the three teams above it. It qualifies because it played five conference games rather than four, so its three wins match the leaders’ three and the candidate-pool rule pulls it into the tie; the SSR step then rates it above Ashwood. Dunmore, which played for the title under the old rules, drops out of the top two entirely.

That is not a quirk to route around. It is the policy the ACC adopted, and the reason it exists is exactly this: teams no longer play the same number of conference games, so raw win percentage stopped being comparable.

PRESENT — the 2026 CFP field

The 2026 automatic-qualifier rule is not “the five best champions”. It is the ACC, Big 12, Big Ten and SEC champions regardless of ranking, the highest-ranked Group-of-6 team whether or not it won its conference, and Notre Dame when it is ranked inside the field.

board <- data.frame(
  sim = 1L,
  team = c("Ashwood", "Brightwater", "Cedarcrest", "Dunmore", "Eastvale",
           "Fairmont", "Glenrock", "Harborview", "Ironside", "Junction",
           "Kingsport", "Lakeshore", "Marbury", "Northgate", "Oakhurst",
           "Pinehurst", "Quarry Ridge", "Riverton", "Notre Dame", "Summit"),
  conference = c("SEC", "Big Ten", "ACC", "Big 12", "SEC", "Big Ten",
                 "SEC", "Big Ten", "ACC", "Big 12", "Mountain West",
                 "American", "SEC", "Big Ten", "Sun Belt", "MAC",
                 "Big 12", "ACC", "FBS Independents", "Conference USA"),
  conf_champ = c(TRUE, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE,
                 TRUE, TRUE, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE,
                 FALSE, FALSE, FALSE, FALSE),
  win_pct = seq(1, 0.4, length.out = 20), sov = 0.5, sos = 0.5, pd = 50
)
# The committee's final order. Kingsport - a Group-of-6 CHAMPION - sits at
# 14, one place behind Lakeshore, a Group-of-6 team that is not a champion.
committee <- data.frame(
  team = c("Ashwood", "Brightwater", "Dunmore", "Eastvale", "Fairmont",
           "Glenrock", "Harborview", "Junction", "Marbury", "Northgate",
           "Notre Dame", "Riverton", "Lakeshore", "Kingsport", "Cedarcrest",
           "Ironside", "Oakhurst", "Pinehurst", "Quarry Ridge", "Summit"),
  rank = 1:20
)

field_of <- function(n, policy = "2026") {
  s <- cfb_playoff_seeds(board, rankings = committee, playoff_seeds = n,
                         autobid = policy)
  s <- s[!is.na(s$seed), ]
  s <- s[order(s$seed), ]
  data.frame(
    Seed = s$seed, Team = s$team, Conference = s$conference,
    Champion = ifelse(s$conf_champ, "yes", ""),
    check.names = FALSE
  )
}
show(field_of(12), "The 12-team field under the 2026 rule",
     "Straight seeding: the field is ordered by committee rank")
The 12-team field under the 2026 rule
Straight seeding: the field is ordered by committee rank
Seed Team Conference Champion
1 Ashwood SEC yes
2 Brightwater Big Ten yes
3 Dunmore Big 12
4 Eastvale SEC
5 Fairmont Big Ten
6 Glenrock SEC
7 Harborview Big Ten
8 Junction Big 12 yes
9 Marbury SEC
10 Notre Dame FBS Independents
11 Lakeshore American
12 Ironside ACC yes

Two rows in that table are the 2026 rule doing work:

  • Lakeshore (American) is in, and is not a champion. The Group-of-6 place goes to the best-ranked G6 team, so the American’s 13th-ranked non-champion takes it.
  • Kingsport, a Group-of-6 champion ranked 14th, misses the field. Outside the Power 4, winning your conference is no longer enough.

Run the same board under the previous era and the difference is explicit:

in26 <- field_of(12)$Team
in25 <- field_of(12, policy = "2025")$Team
data.frame(
  Change = c("In under 2026, out under 2025", "In under 2025, out under 2026"),
  Teams = c(paste(setdiff(in26, in25), collapse = ", "),
            paste(setdiff(in25, in26), collapse = ", "))
) |>
  show("Same board, two automatic-qualifier eras")
Same board, two automatic-qualifier eras
Change Teams
In under 2026, out under 2025 Notre Dame, Lakeshore
In under 2025, out under 2026 Northgate, Kingsport

autobid = "2025" reinstates the five-best-champions rule: Kingsport returns as a champion, Northgate takes the last at-large place, and both Lakeshore and Notre Dame drop out. Two of the twelve places change occupants — four teams affected — on the rule change alone, without a single game being played differently. Ten of the twelve are the same either way, which is the useful part: the eras disagree at the margin, and the margin is exactly where the arguments happen.

FUTURE — stress-testing a 16-team field

Nothing in the seeding code is hard-wired to twelve, so raising playoff_seeds answers “who would expansion have let in?” directly:

show(field_of(16), "The same board as a 16-team field",
     "The automatic qualifiers are unchanged; the extra places are at-large")
The same board as a 16-team field
The automatic qualifiers are unchanged; the extra places are at-large
Seed Team Conference Champion
1 Ashwood SEC yes
2 Brightwater Big Ten yes
3 Dunmore Big 12
4 Eastvale SEC
5 Fairmont Big Ten
6 Glenrock SEC
7 Harborview Big Ten
8 Junction Big 12 yes
9 Marbury SEC
10 Northgate Big Ten
11 Notre Dame FBS Independents
12 Riverton ACC
13 Lakeshore American
14 Kingsport Mountain West yes
15 Cedarcrest ACC
16 Ironside ACC yes
added <- setdiff(field_of(16)$Team, field_of(12)$Team)
data.frame(
  `Teams added by expanding 12 to 16` = paste(added, collapse = ", "),
  check.names = FALSE
) |>
  show("What expansion actually buys")
What expansion actually buys
Teams added by expanding 12 to 16
Northgate, Riverton, Kingsport, Cedarcrest

The four extra places go to Cedarcrest, Kingsport, Northgate and Riverton — three at-larges plus the Group-of-6 champion who missed the 12-team cut. Note what does not change: the automatic qualifiers are the same teams, because the AQ rule governs who is guaranteed a place, not how many places exist. Expansion is felt entirely at the bottom of the bracket.

For a simulated 16-team postseason, pass the same argument to cfb_simulations(). The bracket generator handles any field size, giving byes to the top seeds when the field is not a power of two — 12 seeds produce the familiar 5v12 / 6v11 / 7v10 / 8v9 first round, 16 produce a full first round with no byes.

Feeding the rules real inputs

Several procedures depend on published external numbers that cfbseedR does not compute. Supply them through tiebreaker_data; anything you omit skips its rung deterministically and records the fact:

st <- cfb_standings(
  games, teams,
  tiebreaker_data = list(
    analytics_ratings = my_composite,  # SportSource SSR, SP+, SOR, KPI...
    cfp_rankings = latest_cfp,         # the committee's published top 25
    apr = multi_year_apr               # the CUSA policy's late fallback
  )
)
attr(st, "tiebreak_notes")

Simulating, and checking the simulation

Simulations split into chunks dispatched with furrr, so a parallel plan uses every core, and summary() renders the result as a gt table:

games <- cfb_games_example
teams <- cfb_teams_example
games$result[games$week >= 3] <- NA

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%

Three identities make a good smoke test on any simulation output, and this run satisfies all three exactly:

data.frame(
  Check = c("playoff probabilities sum to the number of seeds",
            "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)
) |>
  show("Simulation sanity checks", "1000 simulations, seed 2026, 4 chunks")
Simulation sanity checks
1000 simulations, seed 2026, 4 chunks
Check Expected Observed
playoff probabilities sum to the number of seeds 4 4
national-title probabilities sum to one champion 1 1
conference titles sum to one per conference 2 2

If any of those drifts, something upstream is wrong — a team missing from teams, a conference with no championship game, or a compute_results function that is not filling every scheduled result.

Scaling up is one argument, and progress reporting is one line:

future::plan("multisession")
progressr::handlers(global = TRUE)

sim <- cfb_simulations(games, teams, simulations = 10000, chunks = 8)

future::plan("sequential")

A seed reproduces exactly for a fixed chunks, and a parallel plan gives identical results to a sequential one — but the RNG stream depends on the chunking, so changing chunks changes which seasons you draw.

Pointing this at a real season

Everything above runs on constructed records so the article builds offline. With cfbfastR installed, the same code takes a real season:

sched <- cfbfastR::load_cfb_schedules(2026)
games <- cfb_games_from_schedule(sched)
teams <- cfbfastR::cfbd_team_info(year = 2026) |>
  dplyr::transmute(team = school, conference)

# today's rules on this season
standings <- cfb_standings(games, teams, playoff_seeds = 12)

# the same season re-examined under the previous era's automatic bids
cfb_playoff_seeds(standings, rankings = latest_cfp, autobid = "2025")

Because the registry is season-scoped, a season column of 2024 or 2025 is automatically ranked under the rules in force then — so year-over-year comparisons stay honest with no flags to remember.

Where the rules come from

Every registered procedure was transcribed from the conference’s official policy (the ACC’s July 2026 policy, the SEC/Big Ten/Big 12 2024 policies, and the published American / CUSA / MAC / Mountain West / Sun Belt procedures) and the CFP’s published formats. The cheat sheet (PDF) condenses this package alongside cfbplotR and cfb4th.

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.