Skip to content

NFL Season Structure — Playoffs & Super Bowl

What changed

The simulation previously produced a flat 18-week regular season with no structure beyond team and week. This PR adds the structural scaffolding needed to run a complete NFL season — regular season through Super Bowl — and makes that structure first-class in the data.

New fields on Team: Conference (AFC/NFC) and Division (e.g. "AFC West") are now assigned from real NFL alignment across all 32 teams.

New field on SeasonGame / Matchup: GameType — a string constant from RegularSeason | WildCard | Divisional | ConferenceChampionship | SuperBowl.

Week numbering is now meaningful end-to-end:

Week Stage
1–18 Regular season
19 Wild Card (6 games)
20 Divisional (4 games)
21 Conference Championships (2 games)
22 Super Bowl (1 game)

SeasonEngine.SimulateSeason now returns all 301 games in one flat list — 288 regular season + 13 playoff. Consumers that only want regular season can filter on GameType == "RegularSeason".

Seeding logic (StandingsEngine): Computes wins/losses and point differential from regular season results. Division winners take seeds 1–4 (sorted by record), wild cards take 5–7. Tiebreaker: head-to-head record → point differential → random.

Playoff bracket (PlayoffEngine): Seed 1 gets a bye. Wild Card pairs 2v7, 3v6, 4v5 (higher seed is home). Divisional reseeds and pairs 1v4, 2v3. AFC champion hosts the Super Bowl by convention.


Impact on play-by-play accumulation

AmericanFootballOutcomeAccumulator and the play accumulators are unaware of game type — playoff games will flow through them unchanged, which is correct for per-game stats. However, two gaps are now visible:

AmericanFootballSeasonAccumulator counts every game passed to it with no filtering. AccumulateGame receives a GameResult, not a SeasonGame, so it has no access to GameType — the filter has to happen at the call site before AccumulateGame is called. Playoff results would inflate W/L records beyond 18 wins if not filtered. Notably, FinalizeWorld already contains a TODO explicitly anticipating this work:

// TODO: Playoff simulation is complex — skip playoff outcomes (MADE_PLAYOFFS, WON_SUPERBOWL, etc.)
// for this initial implementation. Will need conference/division standings and bracket simulation.

Our changes directly unblock that TODO. The practical risk right now is low — the accumulator is only exercised in storage experiment tests where the caller controls which games are passed in. But when wired to SeasonEngine, callers will need to filter on GameType == "RegularSeason" before calling AccumulateGame for standings, and implement the playoff outcome tracking in FinalizeWorld.

GameOutcomeContext has no GameType. A playoff game's context is currently indistinguishable from a regular season game's. Adding GameType (and optionally Week) to the context would allow downstream consumers to slice by stage without joining back to a schedule.


Impact on data storage experiments

PlayByPlayRecord has a Week field but no GameType or Conference/Division fields. The new week numbers (19–22) carry implicit meaning, but a consumer has to hard-code the mapping. The natural next step is to add three columns:

GameType   string   -- "RegularSeason" | "WildCard" | "Divisional" | "ConferenceChampionship" | "SuperBowl"
Conference string   -- "AFC" | "NFC" (possession team's conference)
Division   string   -- "AFC West" etc.

Storage volume per world increases from ~288 to ~301 games. At ~440 plays/game that's roughly 132,000 play records per world vs the previous ~126,000 — about a 5% increase. Negligible for storage sizing, but worth noting in benchmark baselines.

ClickHouse query implications: GameType as a low-cardinality string column is a good candidate for the ORDER BY key or a partition-level filter. Queries like "all Super Bowl play-by-play across N worlds" or "playoff passing stats for AFC teams" would benefit significantly from it. Without it, those queries require a WHERE Week >= 19 scan across the full dataset.


New season queries unlocked

Once GameType propagates to PlayByPlayRecord and the accumulators, these become natural queries against any world dataset:

  • Team's playoff path: WHERE GameType != 'RegularSeason' AND (HomeTeam = 'KC' OR AwayTeam = 'KC')
  • Super Bowl outcomes across worlds: WHERE GameType = 'SuperBowl' GROUP BY WorldId
  • Conference comparison: WHERE Conference = 'AFC' GROUP BY Division, Week
  • Regular season standings: WHERE GameType = 'RegularSeason' GROUP BY HomeTeam (wins/losses without playoff contamination)
  • Scoring patterns by round: average score differential per GameType — are playoff games closer than regular season?

  1. Add GameType to PlayByPlayRecord and the Parquet schema
  2. Filter playoff games out of AmericanFootballSeasonAccumulator's W/L tracking (or add a parallel playoff wins outcome) — unblocks the existing FinalizeWorld TODO
  3. Add GameType to GameOutcomeContext so context-level queries don't need a week-number lookup
  4. Update ClickHouse experiment fixtures to include a GameType partition/ordering key and re-run storage benchmarks with the new baseline of 301 games/world