A House Divided A House DividedDocumentation
Changelog
Game Design/Elections & Campaigns

The Granular Layer-1 Electorate (as shipped)

Last updated 2026-08-20
Source files

The electorate that casts votes in A House Divided is not the 12 voter archetypes any more: it is a lattice of granular demographic cells, the cross-product of Layer-1 census dimensions (race, age, education, wealth in the US; ethnicity, income, urbanization variants abroad), IPF-raked to each state's census marginals. This is the live vote path for every country, not a proposal or a flag: the granularElectorateEnabled OFF branch has been removed from the tally engine, and a null substrate now means only "this state has no census row yet". The core is . This document supersedes design/demographics.md and the two demographic-election-*audit.md documents where they describe the archetype electorate or a flag-gated granular path as current.

Cells, units, and the substrate#

buildGranularElectorateSubstrate produces a drop-in replacement for the legacy engine inputs: a synthetic DemographicCategory (id granularCells), a StateDemographics whose "groups" are cells, live turnouts, and a turnout pool. The vote-distribution engines (voteDistribution.ts, voteDistributionSwingFlow.ts) are unchanged: they iterate whatever groups they are handed, so the appeal formula (policy-distance dominance, favorability weighting, org/reg/support multipliers) applies to cells exactly as it did to archetypes.

Derivation, per state (deriveCellsForState then coalesceCells):

  1. Derive cells. US states use the state census config plus resolved position tables; other countries use their seeded CountryLayer1Model (every country has one). When the era clock is live (gameState.currentYear set), census marginals, positions, and turnout rates are resolved for the YEAR, sliding between era anchors (eraSubstrateForYear.ts); failures degrade to the frozen-preset lookup with a recorded fallback, never a thrown turn.
  2. Prune. Cells below ELECTORATE_PRUNE_FLOOR = 0.0025 (0.25% of the electorate) are dropped and the rest renormalized.
  3. Coalesce. Surviving cells merge into units on quantized leans and turnout (LEAN_QUANT = 0.5 on the shared −5..+5 axes, TURNOUT_QUANT = 5 points), storing share-weighted means and share-weighted bucketWeights ("dim:key" → 0..1). Appeal depends only on a unit's leans, so coalescing is the memoization: each distinct quantized lean pair is scored once per candidate.
  4. Memoize. Unit lists are cached per (country, state, preset, turnout-modifier signature, overlay signatures, year), UNIT_CACHE_MAX = 800 entries. The year and the durable overlays are part of the key because they change the derivation itself.

Unit turnout is clamped to [5, 95]; bucket rates feeding the geometric mean are clamped to [1, 99].

Turnout rates and the vote pool#

Base bucket turnout comes from DEMOGRAPHIC_TURNOUT_RATES in (modern national baselines: race white 63 / black 60 / hispanic 48; age young 38 / mid 56 / mature 66 / senior 76; education no_college 52 / college 66 / graduate 74; wealth low 44 / middle 60 / high 74), replaced by era-resolved tables when the year clock is live. A cell's turnout is the geometric mean of its buckets' rates, rescaled so the population-weighted mean matches the state baseline. GOTV, canvassing, and suppression modifiers compose in three ways: US dim-keyed modifiers add directly to bucket rates; archetype-keyed modifiers project onto buckets via the archetype map; non-US archetype-keyed modifiers scale every cell by the state's aggregate live-vs-static ratio (magnitude preserved, per-group targeting not). Ideology-dim modifiers have no cell axis and are dropped on the granular path (documented limitation).

The substrate's totalPool is Σ over units of statePopulation × share × turnout, where the population basis is state.votingEligiblePopulation when the cohort vectors exist. In this pool replaces resolveTurnout().totalPool and is spread across the general-phase turn window by turnVoteWeight, then scaled by state approval and office strength.

Registration enters separately, per party, from statePartyOrg rows: registration feeds regResistanceMultiplier (Phase 5a persuasion resistance: entrenched registration resists peel), and registrationShare feeds regBaselineMultiplier on worlds whose seeds author partisan baselines (UK era polling). Rows without the fields resolve to a neutral 1.0×.

Mapping archetypes onto cells#

Gameplay systems still author effects against archetypes: character and NPP archetypeApprovals, legislation demographicEffects, Address-driven party-group favorability, GOTV modifiers. projects each of the 12 US archetypes onto 2 or 3 census buckets with weights summing to exactly 1.0 (retirees: age:senior 0.7 + age:mature 0.3; new_immigrants: race:hispanic 0.5 + race:asian 0.3 + race:other 0.2; ideology-dominant archetypes like evangelicals are approximated by census correlates). archetypeValuesToBuckets distributes a value V as V × w per bucket; a unit then picks up Σ bucketWeight × bucketValue, so a cell matching all of an archetype's buckets receives the full value and partial matches a proportional fraction. Non-US countries project through their own seeded composition tables (countryArchetypeBuckets.ts); archetype ids with no mapping are counted in getUnmappedArchetypeDrops rather than silently vanishing. Candidate approvals and ${partyId}:${archetypeId} favorability deltas are remapped onto unit keys through the same projection, clamped to [−100, 100].

Ordinary legislation lean drift still works: the per-archetype delta (live stateDemographics minus the seeded demographicDefaults snapshot) is projected onto buckets and folded into unit leans, clamped to the −5..+5 axis. That fold is deliberately zero-sum for anything that moves live and default together, which is exactly why realignments need their own channel.

Era checkpoints: durable realignment#

declares dated, historically anchored pulls; applies them each turn, strictly after demographicEffects in the stateEffectsAndNationalAggregation phase (both $set the same fields, so concurrency would race a lost update). The registry (ERA_CHECKPOINTS) currently ships seven US checkpoints: the Southern Realignment (triggered by Brown v. Board's docket outcome, 15 years × 48 turns, shifting Deep South rural_traditionalists/evangelicals right by up to +4.0 economic and the race:white bucket directly), the national Civil Rights Act Black consolidation (race:black, −2.5 economic / −1.5 social, all states), the Voting Rights Act enfranchisement (a TURNOUT-axis shift: +40 points for race:black in MS and AL, +20 in LA/GA/SC/VA over 3 years), and Engel, Reynolds, Griswold, and Miranda lean nudges. Checkpoints only run in worlds with startingYear 1953.

Mechanics:

Vote accumulation through the election engine#

The turn phase voteAccumulation (accumulateGeneralElectionVotes in , group 5 of the turn processor) finds every active election in its general phase (no upper bound on endTime, so the final turn's votes are never dropped) and calls into tallyManagement.ts per election. There the substrate is built once per state and swapped in for demographics, categories, live turnouts, pool, enriched candidates, and favorability; the turn's vote slice then flows through distributeVotesByGroupLevelAllocation or distributeVotesBySwingFlow over the units, and the resulting per-candidate votes are added to the persistent ElectionVoteTally. Later phases in the same sequential group (primarySnapshots, electionResolution) resolve races from those accumulated tallies. Presidential races (presidentialElectionEngine.ts), primary stagger waves (primaryStaggerPhase.ts), and the cached state-lean read (cachedStateLean.ts) consume the same substrate, so polls, leans, and votes cannot disagree about who the electorate is.

The demographics tab reads the same units too: aggregates per-bucket share, leans, and turnout as share-weighted sums over unit.bucketWeights, so what the player sees is a projection of the exact electorate the engine counts, not a parallel archetype table.

Turn-by-turn population mechanics#

Two modules run every turn to keep the underlying population vectors (and the historical baseline they're compared against) internally consistent, independent of the vote-distribution machinery above.

cohortFlows.ts, age×sex population advance#

advanceCohort() () advances one region's age×sex population vector (AgeSexVector) by one turn, in a fixed order:

  1. Continuous aging (applyContinuousAging), runs every turn, not gated on a year boundary: 1/turnsPerYear of each age cohort graduates up each turn, smoothing the age structure rather than jumping it once a year.
  2. Mortality (applyMortality), applies a healthcare-modified death rate (healthcareMortalityModifier) to the post-aging vector.
  3. Fertility (computeBirths / splitNewbornsBySex), converts the population.birthRate index to a TFR via birthRateIndexToTFR() (anchored to the preset's replacementTFR), computes births from the surviving vector (excluding women in mandatory military service from the childbearing pool when servingFemaleByAge is supplied, the conscription interaction), and splits newborns by sex into age-0.
  4. International migration (applyInternationalMigration), applies the region's allocated share of national net international migrants (can be negative), using a migrant age/sex profile derived from migrantShareMale.

All flows are per-cell non-negative except the international-migration step, which can change total headcount. There is no cross-region internal migration yet (tracked as a future phase). The function returns both the advanced vector and a CohortFlowTallies summary (births, deaths, netMigration) for turn reporting.

checkpointBakedShifts.ts, de-duplicating era checkpoints against authored history#

Era checkpoints (eraCheckpoints.ts, see above) model historical realignments as a live per-turn pull. But the era anchor tables they pull toward are themselves authored from real election results, which already contain that history baked in, e.g. Alabama's race:white economic lean is authored at −3.0 in 1953 and +2.5 in 1979, and that +5.5 swing IS the Southern Realignment. Interpolating between those anchors as a live baseline while ALSO running the checkpoint's durable overlay would double-count the same historical shift, pinning the state at the ±5 axis edge.

bakedCheckpointBucketShifts(stateId, axis, year, startingYear, countryId) computes exactly how much of each checkpoint's totalShift real history had already delivered by a given year (historicalDeliveredFraction(): 0 before the checkpoint's historicalWindow opens, linear inside it, 1 after it closes), summed per "dim:key" bucket. That amount is subtracted from the interpolated era baseline so that interpolated baseline + durable overlay == the authored anchor at every anchor point, with smooth movement between anchors. This only runs for startingYear === 1953 worlds (CHECKPOINT_SEED_YEAR), the only preset where checkpoints fire at all, any other starting year returns an empty record and the authored anchors are used untouched. The subtraction uses real-world calendar years from historicalWindow, not in-world turn counts, so it stays a pure function of the year regardless of how a particular world's SCOTUS docket paced the underlying checkpoint.