A House Divided A House DividedDocumentation
Changelog
Game Design/Legislature & Parties

Policy System

Last updated 2026-08-20
Source files

Overview#

The policy system tracks where characters and the game world stand on policy issues across two dimensions: economic (fiscal/regulatory spectrum) and social (cultural/rights spectrum). These positions are stored at two levels:

  1. Character policy positions - each player character's personal stance on the economic and social axes (-5 to +5)
  2. State/national policy records - the current legislative status quo per legislation type, stored in the statePolicies collection (-3 to +3 per axis)

The two levels are related but distinct. A character's personal positions affect how demographic groups vote in elections and how visible policy alignment is to voters. The statePolicies records reflect what the government has actually enacted through legislation and drive ongoing metric effects each turn.


Player Policy Positions#

Scale#

Each character has a policies object with two integer fields:

interface PolicyPositions {
  economic: number; // -5 to +5
  social: number; // -5 to +5
}

Changing Positions - Policy Shift Action#

Players shift their policy position one step at a time via POST /api/settings/policy.

Parameter Values
axis "economic" or "social"
direction 1 or -1

Cost per shift:

Resource Cost
Actions 15
Political Influence −5% (floor 0; Math.floor(current × 0.95))
National Influence −5% (same formula)
Infamy +5

The new value is clamped to −5..+5; requests that would go out of bounds are rejected with a 400 error.

A policy_shift achievement is awarded on the first successful shift.

Changing Positions - Bill Votes#

Voting on bills does not currently shift character policy positions in the codebase. The economic/social scores on a bill's provisions indicate the ideological direction of the bill, but no automatic position adjustment is applied to the voting character.


State and National Policy Tracking#

Collection: statePolicies#

Each record in statePolicies represents the current legislative standing for one legislation type, either at the national level or for a specific state.

Key fields on StatePolicyRecord:

Field Type Description
scope "national" | "state" Whether this record is a federal position or a state-level position
stateId string (optional) Present when scope = "state" (e.g. "CA", "TX")
legislationTypeId string References a LegislationType._id
economic number (−3 to +3) Economic axis position; 0 = center
social number (−3 to +3) Social axis position; 0 = center
updatedAt Date Last modification timestamp

Legislation types marked nationalOnly: true only have national-scope records. Types with allowedScope: "both" or "state" have per-state records.

Seeding#

Base policy records are seeded from :

Run npm run seed:policies to upsert, or npm run seed:policies:reset to clear and reseed.

Position Matching#

The statePolicies position (economic, social) is an integer pair, not a named label. The API matches this pair against the legislation type's policyOptions array to return a human-readable policyOptionName:


Policy to Demographics (Elections)#

Character policy positions are compared to the leans of each demographic group when calculating vote appeal. The live appeal curve uses exponent 1.5 (APPEAL_POSITION_EXPONENT), not the retired squared/gamma=2 curve. For the exact formula and pipeline, see elections.md - specifically the Appeal step in the Total Appeal System.

Summary: candidates closer to a demographic group's lean on the economic and social axes receive higher appeal scores with that group, which translates to a larger share of that group's votes per turn during general elections.


Policy to Legislation (Bills and Archetype Approval)#

When a bill is enacted, each provision's legislation type carries economic and social scores that define the ideological direction of that policy shift. This information is used in two ways:

  1. State metrics: applyLegislationEffect() applies the provision's delta to the relevant state or national metrics via effectTarget.
  2. Archetype approval: Legislators who voted FOR the enacted bill receive approval changes from their demographic archetypes. The impact is calculated from the difference between the old and new policy index for that legislation type, multiplied by each archetype's domain affinity. For the full formula, see bills-legislation.md - the Archetype Approval Impacts section.

Active statePolicies records also drive per-turn metric effects through policyOptions[].metricEffects. Each turn a policy is in effect, the matched option's metricEffects array applies direct additive changes to specific state or national metrics (e.g. −0.04%/turn to uninsured rate while a given healthcare option is active).


Turn Processing - Policy Effects Engine#

Entry point: processStatePolicyEffects()

Processing Order (per turn)#

  1. Exponential decay toward target - Metrics move toward policy-driven targets
  2. Direct tick rates - metricEffects apply additive changes
  3. Natural decay - All metrics decay 0.25%/turn toward baseline when no policy is active

Exponential Decay Formula#

// src/lib/policyEffects.ts:325-326
const newValue = applyPolicyDecay(currentValue, target);

// @shared/constants/formulas.ts
export function applyPolicyDecay(current: number, target: number): number {
  const DECAY_RATE = 0.02; // 2% per turn
  return current + (target - current) * DECAY_RATE;
}

Metrics approach their target asymptotically - large gaps close faster than small ones.

Target Calculation#

// src/lib/policyEffects.ts:145-221 calculateMetricTarget()

let totalContribution = 0;

for (const policy of policies) {
  const legType = legTypeMap.get(policy.legislationTypeId);

  // Weighted effect targets (preferred)
  if (legType.effectTargetsWeighted) {
    for (const target of legType.effectTargetsWeighted) {
      let contribution = calculatePolicyContribution(
        policy.effectDirection * 3, // -3 to +3 range
        target.weight,
        policy.scopeMultiplier,
        isHigherBetter
      );

      // Time-based decay (adaptation modeling)
      if (target.adjustmentHalfLife) {
        const turnsElapsed = currentTurn - policy.enactedTurn;
        const decayFactor = applyHalfLifeDecay(1, turnsElapsed, target.adjustmentHalfLife);
        contribution *= decayFactor;
      }

      totalContribution += contribution;
    }
  }
}

return baseline + totalContribution;

Federal Multiplier (Country-Aware)#

Federal policies apply at reduced strength per state/region:

// @shared/constants/formulas.ts
export function getFederalMultiplier(countryId: CountryId): number {
  if (countryId === "US") return 1 / 50; // 50 states
  if (countryId === "UK") return 1 / 12; // 12 UK regions
  return 1;
}

Rationale: The sum of per-region effects equals the intended national total. Prevents a single federal bill from dominating state metrics.

Time-Based Effect Decay#

Some policies have adjustmentHalfLife - models economic adaptation:

// Example: Defense cuts hurt GDP initially, but economy adapts
const decayFactor = applyHalfLifeDecay(1, turnsElapsed, adjustmentHalfLife);
// After N half-lives: effect = initial × (0.5)^N
Half-Life (turns) Effect after 48 turns (1 game-year)
24 25% of original
48 50% of original
96 75% of original

Direct Tick Rates#

Policy options apply additive per-turn changes:

// src/lib/policyEffects.ts:341-363
const tickRates = computeTickRates(policies, legTypeMap);

for (const [category, metricRates] of Object.entries(tickRates)) {
  for (const [metricId, rate] of Object.entries(metricRates)) {
    if (rate === 0) continue;
    const newVal = Math.max(minVal, Math.min(maxVal, base + rate));
    // Example: rate = -0.04%/turn for uninsured rate
  }
}

Effect scale: Extreme policies ≈ ±0.06/turn, center = 0. Designed for ~10-year full reversal.

Natural Metric Decay#

When no policy is active, metrics decay toward baseline:

// src/lib/policyEffects.ts:22-24
// Natural metric decay: 0.25%/turn toward baseline
// Prevents permanent deviation after legislation expires

Turn Processing - Demographic Effects#

Entry point: processAllStateDemographics()

Demographic Shift Mechanics#

Policies gradually shift demographic group populations:

// src/lib/demographicEffects.ts:46
export const SHIFT_RATE_PER_TURN = 0.1; // 0.1% max shift per turn

// src/lib/demographicEffects.ts:54-86 calculateDemographicShifts()
const strength = policy.economic / 3; // Normalize -3..+3 to -1..+1
const shiftAmount = effect.direction * strength * policy.scopeMultiplier * SHIFT_RATE_PER_TURN;
shifts[effect.groupId] = (shifts[effect.groupId] ?? 0) + shiftAmount;
Policy Strength Normalized Max Shift/Turn
+3 (extreme right) +1.0 +0.1%
+1 (moderate) +0.33 +0.033%
0 (center) 0 0
-1 (moderate left) -0.33 -0.033%
-3 (extreme left) -1.0 -0.1%

Federal Division#

Federal demographic effects also use the country-aware multiplier:

// US: 1/50 per state, UK: 1/12 per region
const federalPolicies = statePolicies.get("federal").map((p) => ({
  ...p,
  scopeMultiplier: getFederalMultiplier("US"), // 0.02
}));

Bulk Processing Optimization#

Both policy and demographic effects use bulk-fetch and bulk-write:

// src/lib/policyEffects.ts:393-400
const [states, allStatePolicies, allLegTypes, allStateMetrics, allStateBaselines] =
  await Promise.all([...]);  // Parallel fetch

// Collect updates, single bulkWrite
if (bulkOps.length > 0) {
  await db.collection("stateMetrics").bulkWrite(bulkOps);
}


API#

GET /api/policy#

Returns current policy records joined with legislation type metadata. No authentication required.

Query parameters:

Parameter Required Description
scope Yes "national" or "state"
stateId When state State abbreviation (e.g. "CA")
country No "us" or "uk" - filters legislation types by country scope when scope=national

Response: Array of PolicyRecordResponse objects:

Field Type Description
legislationTypeId string Legislation type identifier
name string Human-readable legislation type name
policyDomain string Domain (e.g. "healthcare", "economy")
economic number Current economic axis position (−3 to +3)
social number Current social axis position (−3 to +3)
nationalOnly boolean Whether this type is federal-only
policyOptionName string | null Name of the matched policy option (exact or nearest)
hasEconomic boolean True if this type has any option with a non-zero economic score
hasSocial boolean True if this type has any option with a non-zero social score
metricEffects array Per-turn metric effects from the matched option (category, metricId, ratePerTurn)

Cache-Control is set to no-store on all responses.

Error: Returns 400 when scope is missing, invalid, or scope=state is used without stateId.

POST /api/settings/policy#

Shifts the authenticated character's policy position. See Player Policy Positions - Policy Shift Action above.


Database#

Collection: statePolicies

Indexes used: { scope, stateId } for state queries; { scope: "national" } for national queries.

Related collections:

Key type files:

Note: StatePolicyRecord (in legislation.ts) and StatePolicy (in statePolicy.ts) are different interfaces. StatePolicyRecord is what the policy API reads - the current axis positions per type. StatePolicy is a richer enacted-bill record that also stores enactedAt, enactedByBillId, and effectDirection.


Display#