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?
Recommended follow-up¶
- Add
GameTypetoPlayByPlayRecordand the Parquet schema - Filter playoff games out of
AmericanFootballSeasonAccumulator's W/L tracking (or add a parallel playoff wins outcome) — unblocks the existingFinalizeWorldTODO - Add
GameTypetoGameOutcomeContextso context-level queries don't need a week-number lookup - Update ClickHouse experiment fixtures to include a
GameTypepartition/ordering key and re-run storage benchmarks with the new baseline of 301 games/world