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

Elections

Last updated 2026-08-21
Player wikiElections: A Granular Player Guide
Source files

Overview#

Elections run perpetually across five race types: House, Senate, Governor, State Senate, and President. Each election has a primary phase (intra-party competition) followed by a general phase (inter-party competition). One turn = one hour; timers advance each turn.

Election Types#

Type Seats Scope Notes
House 435 total (by state) Per state Multi-seat; proportional allocation
Senate 100 (2 per state) Per state, per class Single-seat; 3 staggered classes
Governor 50 (1 per state) Per state Single-seat
State Senate Varies Per state Multi-seat; proportional allocation
President 1 (national) National (state: "US") Electoral College; 270 to win

Seat Identifiers#

Each political seat has a stable seatId that persists across election cycles, enabling external systems (Discord bots, APIs) to reliably track races.

Format: {countryId}-{electionType}-{localRegionId}[-{senateClass}]

Seat seatId
Pennsylvania Senate Class 1 US-senate-PA-1
Pennsylvania House US-house-PA
California Governor US-governor-CA
U.S. President US-president
London Commons UK-commons-LON
Scotland Commons UK-commons-SCO

Historical race IDs append the cycle number: US-senate-PA-1-c3 (third cycle).

The seats collection stores permanent seat documents with display names. Elections reference seats via the seatId field. Source: , .

Election Phases#

Primary Phase#

General Phase#

Duration by Race Type#

48 hours = 1 year in game time.

Type Total Duration Primary Duration General Duration
House 96 hours (2 years) 48 hours 48 hours
Senate 288 hours (6 years) 240 hours 48 hours
Governor 192 hours (4 years) 144 hours 48 hours
State Senate 192 hours (4 years) 144 hours 48 hours
President 192 hours (4 years) 144 hours 48 hours

Senate classes staggered by 96 hours (2 years): Class 1 now, Class 2 +96h, Class 3 +192h.

Durations are configurable per election; new elections inherit from the most recently completed election for that slot. Source: (DEFAULT_DURATIONS).

Turn Processing Order#

Election-related steps in processTurn() ():

  1. NPP behavior, NPPs cast speaker votes, bill votes, evaluate dropout, and enter elections (processNPPTurn)
  2. Bill lifecycle
  3. Campaign turn (income, actions, maintenance)
  4. Candidate party sweep, withdraws stale candidacies after party switches
  5. Resolve primaries, eliminates losers when primaryEndTime passes; must run before vote accumulation
  6. Accumulate general election votes, one turn of votes for all active general-phase elections; must run before timer advancement
  7. Advance election timers, marks elections "completed" when endTime passes
  8. Record primary snapshots, for elections still in primary phase (trend data)
  9. Resolve completed general elections, determines winners, updates electedOfficials, spawns next cycle
  10. Vacate leadership, removes leadership from members who lost/changed office
  11. Ensure perpetual elections, spawns missing elections for all race types
  12. Clean up stale candidates, withdraws candidates still attached to completed elections

Critical ordering (documented in turnSystem.ts): primaries resolve → votes accumulate → timers advance → elections resolve. Reordering would lose final-turn votes or include eliminated candidates in tallies.

Primary Resolution#

When primaryEndTime passes, resolvePrimariesIfNeeded() in :

  1. Finds elections with primaryEndTime <= now and endTime > now

  2. For each party with multiple candidates, computes a primary score per candidate. The raw score is then multiplied by an infamy penalty, 1 − 0.05 × (infamy/100), for player characters (NPPs aren't affected).

    State-level races (House, Senate, Governor, State Senate):

    • Alignment, state: 25 − (|econ − stateEconLean| + |social − stateSocialLean|) × 1.25, max 25 pts (when state cached lean is available)
    • Alignment, party: 15 − (|econ − partyEcon| + |social − partySocial|) × 0.75, max 15 pts
    • Favorability: (favorability / 100) × 35, max 35 pts
    • Political Influence: normalizeNPI(politicalInfluence) × 25, max 25 pts (sqrt curve, capped at 1.0 once PI reaches 100)
    • Fallback when state lean is missing: alignment collapses to a single 40-pt party-only check (max(0, 40 − (|econDiff_party| + |socialDiff_party|) × 2.0)), preserving the pre-rework formula.

    Presidential (national, no state-position component):

    • Alignment: 40 − (|econ − partyEcon| + |social − partySocial|) × 2.0, max 40 pts
    • Party Influence (candidate's accumulated party clout): normalizePartyInfluencePresidentialPrimary(partyInfluence) × 20, weight 20 (PRESIDENT_PRIMARY_PARTY_INFLUENCE_WEIGHT)
    • National Influence: normalizeNationalReachPresidentialPrimary(nationalInfluence) × 15, max 15 pts (linear-up-to-cap, NPI ≥ 100 saturates at 1.0)
    • Favorability: (favorability / 100) × 25, max 25 pts
  3. Highest score per party advances; others marked withdrawn

  4. Win/loss notifications sent to player candidates

  5. General vote tally initialized (or created if missing)

Vote Accumulation (General Phase)#

Handled by and accumulateVoteTurn().

Model#

FPTP & RCV, Vote-Splitting System#

The voting system used by each state determines whether the vote-splitting (spoiler) effect is applied after the group-level allocation.

First Past the Post (FPTP), Default#

In FPTP states, third-party candidates create an explicit spoiler effect:

  1. After group-level votes are distributed, for each third-party candidate in the race, FPTP_SPOILER_RATE (4%) × the third party's own group-level allocation is drawn from the ideologically nearest major-party candidate (per getMajorPartiesForRegion(countryId, parentRegionId), e.g. Democrat/Republican in the US, Labour/Conservative in England, SNP/Labour in Scotland) and transferred to the third party.
  2. "Nearest" is measured by Manhattan distance on the economic / social policy grid.
  3. This models the real-world vote-splitting dynamic: a Green Party candidate on the left bleeds coalition voters from the Democratic candidate, potentially handing the race to the Republican.

Political implications (design intent):

Example (FPTP, Texas):

Candidate Party Group-level votes After FPTP spoiler
Adams Democrat 400,000 392,000 (−8,000)
Brooks Republican 450,000 450,000 (unaffected)
Chen Green 200,000 208,000 (+8,000)

Green is ideologically near Democrat. 4% × 200,000 = 8,000 spoiled, drawn from Dem. Rep is unaffected.

The key outcome: Democrat loses 8,000 votes; Green gains 8,000, but Green still finishes second. Republican wins with the plurality. In a tighter race the 8,000-vote swing is enough to flip the seat.

Ranked Choice Voting (RCV), Optional (legislated per state)#

In RCV states, no vote-splitting adjustment is applied. Third parties compete on equal footing with major parties:

Implementation: FPTP_SPOILER_RATE is defined in . Major parties for the spoiler step come from getMajorPartiesForRegion() in , the same helper used in and in-race poll math in (invoked from ). The voting system is stored per state in states.votingSystem ("fptp" | "rcv", defaults to "fptp").

Total Appeal System (Pipeline)#

Two distribution paths exist. Primaries and commissioned polls use the group-level competitive allocation model described below (each demographic group votes as a bloc, splitting its vote pool among candidates by relative appeal). General-election vote accumulation defaults to the swing-flow model (distributeVotesBySwingFlow() in , set unconditionally in tallyManagement.ts); group-level allocation remains available as a fallback/legacy path for generals but is not the live default. See Election Engine for the swing-flow driver stack (coattails, median-voter, persuasion, party-tenure fatigue, incumbency). The appeal/reach/approval math below is the shared foundation both paths build on.

The full pipeline from candidate and state to votes per turn (shared with poll and NPP dropout via ):

Stat used by race type:

  • State races (House, Senate, Governor, State Senate): use Political Influence (politicalInfluence, capped 0-100)
  • Presidential race: use National Political Influence (nationalInfluence)
  1. Reach, normalizeNPI(influence): fraction of turned-out voters the candidate can reach. Sqrt curve, hard-capped at 1.0 once PI/NPI reaches 100. Both state and presidential general elections use this curve. Presidential primary reach uses the separate diminishing-return function 1 - exp(-NPI/45).
  2. Appeal (per demographic group), Position score 25 × (positionRaw/50)^1.5 + floor, where positionRaw = max(0, 50 − |econDiff|×5 − |socialDiff|×5) and APPEAL_POSITION_EXPONENT = 1.5 (), plus a directional (tribal-voter) bonus of up to DIRECTION_BONUS_PER_AXIS = 5 per axis, plus normalizeNPI(influence) × 12.5 when influence is included. Max 50 (position ~25 + influence ~25 at PI=100). Does NOT include favorability, that scales at the end. (γ=2, the legacy squared curve, is still supported as a special case but is not the live default.)
  3. Group-level competitive allocation, Each demographic group contributes to the turn pool proportionally to its size. Within each group, candidates split that contribution by relative (appeal × reach × approval × partyOrg × infamyMult). Groups vote as blocs; higher appeal with a group yields a larger share of that group's votes.
  4. Approval scalar, (favorability / 100)^0.8: voters will not support candidates they do not approve of, while the exponent softens the middle of the curve. 0% approval = 0 votes.
  5. Party org scalar, General elections: normalizedOrgShare ^ ORG_WEIGHT_EXPONENT (ORG_WEIGHT_EXPONENT = 0.2), the party's normalized share of statewide Org among all parties, with diminishing returns; no Org data anywhere falls back to a neutral 1×. Primaries use a uniform neutral 1× (intra-party Org cancels). This retired the older flat 0.5 + (org/100)×0.5 scalar (2026-06-18).
  6. Infamy scalar, 1 − 0.05 × (infamy/100): player characters lose up to 5% of their per-group weight at infamy=100. NPPs leave infamy unset and are unaffected.
  7. Party strength modifier, (1 + (approvalDecimal − 0.5) × 0.2) × officeStrength for state races (× 0.5 in place of × 0.2 for president, so presidential races feel state approval 2.5x more strongly):
    • State government approval: 0-100% from state metrics vs. national averages (see Government Approval). When metrics are missing, 50% is used. At 50% approval the modifier is 1.0x baseline regardless of office; state races swing ±10% (0% approval → 0.9x, 100% → 1.1x) before the office-strength factor, president swings ±25% (0.75x-1.25x).
    • Office strength: Governor 1.0, House 0.9, Senate 0.8, State Senate 0.85.
  8. Effective turn pool, Base turn pool × party strength modifier. Same modifier for all candidates in that election; relative shares are unchanged.
  9. Distribution, For each group, its share of the effective turn pool is split among candidates by relative (appeal × reach × approval × partyOrg × infamyMult). Votes per candidate are summed across all groups.

Factors Affecting Votes#

Factor Effect
Policy alignment Power curve, exponent 1.5: 25 × (positionRaw/50)^1.5 + floor, positionRaw = 50 − econDiff×5 − socialDiff×5; closer = higher appeal
Political influence (PI) State races only, reach + appeal score. Capped 0-100; max reach = 1.0
National Political Influence (NPI) Presidential race only, sqrt scaling via normalizeNPI, hard-capped at 1.0 once NPI reaches 100. Above 100 it saturates (no celebrity bonus).
Favorability (approval) Final scalar: (favorability/100)^0.8 (APPROVAL_SCALAR_EXPONENT); 0% approval = 0 votes
Party org Final scalar in general elections: normalizedOrgShare ^ 0.2 (ORG_WEIGHT_EXPONENT), a party's normalized share of statewide Org with diminishing returns. Primaries use a uniform neutral 1×.
Government approval Scales the turn pool by (1 + approval); high-approval states allocate more votes per turn
Office strength Governor 1.0, House 0.9, Senate 0.8, State Senate 0.85, governor races most affected by approval
Voting system (FPTP/RCV) FPTP: third-party candidates gain FPTP_SPOILER_RATE × their own allocation drawn from nearest major party (spoiler effect). RCV: no adjustment.

Campaign actions raise favorability and influence; ads raise recognition and favorability.

Polls vs simulated votes#

State and general elections (House, Senate, Governor, State Senate, Commons, etc.): Commissioned polls use the group-level competitive allocation and FPTP spoiler rules, including region-aware major parties and per-archetype effective favorability for opponents when that data exists (, ). Turn-by-turn general-election vote accumulation itself defaults to the swing-flow model instead (see above); polls are an appeal-based projection and do not replay the swing-flow driver stack.

Presidential: Simulated votes are accumulated per electoral unit in (averaged party vs character positions, national influence for reach, influence included in appeal, state/district lean, independent penalty, swing-state ground game). The FPTP spoiler step is applied at half rate (PRESIDENTIAL_SPOILER_RATE = 2%) on the presidential distribution path to prevent fragmented fields from producing EC landslides via winner-take-all. Player polls remain home-state projections and do not replicate the national presidential model, treat poll topline and in-race breakdowns as indicative for the character’s state, not as an Electoral College forecast.

Multi-Seat Races (House, State Senate)#

Senate Elections#

Governor Elections#

Candidacy Rules#

Election Entry API#

Endpoint: POST /api/elections/[id]/enter

Validation:

// src/app/api/elections/[id]/enter/route.ts

// 1. Election must be upcoming or active
if (election.status !== "upcoming" && election.status !== "active") {
  return 400; // "This election is not open for entry"
}

// 2. Primary deadline enforced
if (primaryEnded) {
  return 400; // "The primary entry period has ended"
}

// 3. Country restriction
if (electionCountry !== characterCountry) {
  return 403; // "This election is for {X} characters only"
}

// 4. Home state restriction (president is national)
if (!isPresident && election.state !== character.homeState) {
  return 403; // "You can only run for office in your home state"
}

// 5. One race at a time check
const blocking = await findBlockingActiveCandidacy(db, character._id, electionObjectId);
if (blocking) {
  return 400; // "You are already running in {race}"
}

Party switching: If already entered under a different party, the old candidacy is auto-withdrawn before creating the new one.

Achievement trigger: Election entry triggers achievement checks via checkElectionEntryAchievements().

Withdrawal Mechanics#

Endpoint: POST /api/elections/[id]/withdraw

Effects:

// src/app/api/elections/[id]/withdraw/route.ts

// 1. Mark candidate as withdrawn
await db
  .collection("electionCandidates")
  .updateOne({ _id: candidate._id }, { $set: { status: "withdrawn", withdrawnAt: now } });

// 2. Remove votes from tally
await removeWithdrawnCandidateFromTally(db, electionObjectId, candidate._id.toString());

// 3. Delete campaign document
await db.collection("campaigns").deleteOne({
  electionId: electionObjectId,
  candidateId: candidate.characterId,
});

Restrictions:

Election Continuity#

All race types run perpetually:

Polling & Display#

Presidential Election#

API: POST /api/elections/[id]/running-mate

// src/app/api/elections/[id]/running-mate/route.ts

// Validation:
// - Only the candidate can set their own running mate
// - Running mate cannot be the current President
// - Running mate cannot be the same person (self-selection)
// - Accepts character ObjectId or "" to clear

await db
  .collection<ElectionCandidate>("electionCandidates")
  .updateOne(
    { _id: myCandidate._id },
    { $set: { runningMateId: runningMateObjectId, updatedAt: new Date() } }
  );

NPP Election Participation#

See NPP System for full documentation. NPPs autonomously enter and drop out of elections each turn via processElectionEntry() in .

Entry#

Dropout / Elimination#

Spawn (Admin)#

Admin → Elections → NPP Management: spawn 1-500 NPPs per party with weighting (lean / members / both).

Presidential Travel#

During the general election phase, presidential candidates can travel to specific states to campaign in person.

API: POST /api/elections/[id]/travel

// src/app/api/elections/[id]/travel/route.ts

const actionCost = getTravelActionCost(stateId, gameState?.preset);

// Validates:
// - Election is presidential and active
// - User is an active candidate
// - Has enough actions (cost varies by state EV)
// - State is a valid US state

await db
  .collection<ElectionCandidate>("electionCandidates")
  .updateOne({ _id: candidate._id }, { $set: { travelState: stateId, traveledAt: now } });

Travel is a presidential-only mechanic. State-level races use standard campaign/ads actions instead.

Campaign Strategy#

Building Support#

Attack Strategy#

Geographic Strategy#

Database Collections#

Collection Purpose
elections Active, upcoming, completed elections
electionCandidates Candidates per election; status active/withdrawn
electionVoteTallies General-phase vote totals, snapshots, seats estimate
primarySnapshots Hourly primary standings for trend display

Election Engine Module Structure#

The election vote calculation pipeline is implemented across two modules:

src/lib/electionEngine/, Core vote calculation engine#

File Purpose
voteDistribution.ts Group-level competitive allocation: distributes votes by demographic blocs using appeal × reach × approval × partyOrg; applies FPTP spoiler effect. Used for primaries and polls; fallback/legacy path for generals
voteDistributionSwingFlow.ts Two-phase pairwise swing-flow allocation; default vote model for general elections. Layers coattails, median-voter, persuasion, party-tenure fatigue, and incumbency drivers on top of the base appeal calculation
tallyManagement.ts Accumulates vote turns, initializes tallies, computes seat estimates for multi-seat races
voteCalculations.ts Turn vote weight formula (three-tier closing surge: 50% early / 20% ramp / 30% final 4 turns), state turnout calculation
resolvedTurnout.ts Combines static StateDemographics.turnout with dynamic StateDemographicTurnout.modifiers from GOTV/canvassing/suppression
candidateEnrichment.ts Fetches character/NPP data and merges with candidate records for vote calculations
types.ts EnrichedCandidate, DistributeVotesOptions, AccumulateVoteTurnPreload
constants.ts FPTP_SPOILER_RATE (0.04), PARTY_STRENGTH_BY_OFFICE (deprecated, now in CountryConfig)

src/lib/elections/, API helpers and election lifecycle#

File Purpose
electoralVoteService.ts Presidential Electoral College computation, per-unit EV allocation, map data, EV-by-turn tracking
buildPollingData.ts Poll computation using same group-level allocation as vote accumulation
enrichElection.ts Full election enrichment with candidates, tally, polling data
resolveElection.ts General election resolution: winner determination, seat allocation, NPP influence updates
candidateEnrichment.ts Alternative enrichment for API responses (includes party logos, campaign IDs)
electionParamResolution.ts Resolves election duration, primary duration from config or previous cycle
voteTallyService.ts Simple tally fetcher for API responses
phases.ts Computes election phase (upcoming/primary/general/ended) from timers and game time
activeCandidacy.ts Checks if a character has an active candidacy (for validation)
electionResponseTypes.ts TypeScript types for API response shapes

Vote Accumulation Pipeline#

Each turn, accumulateVoteTurn() in tallyManagement.ts runs the following pipeline:

  1. Fetch tally + candidates, Load ElectionVoteTally and active ElectionCandidate records
  2. Load state context, State demographics, categories, party orgs, turnout modifiers
  3. Resolve effective turnout, resolveTurnout() combines baseline turnout with GOTV/canvassing/suppression modifiers
  4. Compute turn pool, turnVoteWeight() allocates the pool in a three-tier closing surge: 50% early, 20% in the 8-turn ramp band, 30% in the final 4 turns
  5. Apply party strength, State government approval × office strength scales the pool
  6. Enrich candidates, fetchEnrichedCandidates() merges character/NPP stats (policies, favorability, influence, archetype approvals)
  7. Distribute votes, general elections call distributeVotesBySwingFlow() (default); primaries call distributeVotesByGroupLevelAllocation():
    • For each demographic group: compute appeal, reach, approval, partyOrg
    • Split group's share of pool proportionally by candidate weights
    • Apply FPTP spoiler effect (if general election in FPTP state): transfer FPTP_SPOILER_RATE × thirdPartyVotes from nearest major party
    • Swing-flow additionally layers coattails, median-voter, persuasion, and incumbency drivers, see Election Engine
  8. Update tally, Accumulate votes, update shares, compute seat estimates for multi-seat races
  9. Snapshot, Push turn snapshot to turnSnapshots array for trend tracking

FPTP Spoiler Effect#

In FPTP states (default), third-party candidates create a spoiler effect:

// From voteDistribution.ts:145-166
for (const tp of thirdParties) {
  const spoiled = votesPerCandidate[tp.candidateId] * FPTP_SPOILER_RATE; // 4% of third-party votes
  // Find ideologically nearest major party (Manhattan distance on EP/SP grid)
  let nearest = majorParties.findClosest(tp.charEP, tp.charSP);
  votesPerCandidate[nearest.candidateId] -= spoiled;
  votesPerCandidate[tp.candidateId] += spoiled;
}

This models real-world vote-splitting: a Green Party candidate bleeds Democratic votes; a Libertarian bleeds Republican votes. RCV states skip this step entirely.

Presidential Electoral College#

Presidential elections use computeElectoralVotes() in electoralVoteService.ts:

Elections Hub#

The /elections page is the central listing of all elections across all race types and states.

County & Congressional District Maps#

State-level results for presidential, governor, and senate elections can be explored at /elections/[id]/state/[stateId].

County Maps#

Congressional District Maps#