Policy System
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:
- Character policy positions - each player character's personal stance on the economic and social axes (-5 to +5)
- State/national policy records - the current legislative status quo per legislation type, stored in the
statePoliciescollection (-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
}
- Negative values are left/progressive; positive values are right/conservative.
- Set at character creation; changed only through the explicit policy shift action or by voting on bills.
- NPPs also carry
PolicyPositionsand use the same fields in election calculations.
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 :
- National defaults: Per-type baseline values (e.g. tax policy +1, healthcare −1) defined in
US_NATIONAL_DEFAULTSandUK_NATIONAL_DEFAULTS. - State defaults: Derived from each state's
politicalLeanproperty; bluer states get more negative values, redder states more positive. Clamped to −3..+3.
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:
- Exact match: finds the option where
o.economic === economic && o.social === social. - Nearest neighbor: if no exact match, selects the option with the minimum Manhattan distance (
|econDiff| + |socialDiff|).
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:
- State metrics:
applyLegislationEffect()applies the provision's delta to the relevant state or national metrics viaeffectTarget. - 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)#
- Exponential decay toward target - Metrics move toward policy-driven targets
- Direct tick rates -
metricEffectsapply additive changes - 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:
legislationTypes- providespolicyOptions,policyDomain,nationalOnly,countryScope, andeffectTargetsWeightedused by the policy API and turn systemcharacters- stores per-characterpolicies: { economic, social }(−5 to +5)
Key type files:
- -
LegislationType,LegislationPolicyOption,StatePolicyRecord,PolicyOptionMetricEffect - -
StatePolicy(the enacted-bill record),PolicyReaction,VoteImpact - -
PolicyPositions
Note:
StatePolicyRecord(inlegislation.ts) andStatePolicy(instatePolicy.ts) are different interfaces.StatePolicyRecordis what the policy API reads - the current axis positions per type.StatePolicyis a richer enacted-bill record that also storesenactedAt,enactedByBillId, andeffectDirection.
Display#
- National Policy page (
/policy): Lists all national policies grouped by domain. Shows current policy option name and economic/social positions per type. - State page - State Laws & Policy tab: State-level policy records for the selected state. Empty if
seed:legislationandseed:policieshave not been run. - Character profile: Shows the character's personal economic and social positions on the −5 to +5 scale.