Architecture¶
Data-driven engine, YAML all the way down¶
Metrics defines an enormous amount of tabular data (district stats, building types, metric behaviours, threat chains, remedies, detection times). All of that lives in YAML. The Python engine just reads the rules and runs them.
Directory structure¶
ankh-crisis-sim/
├── config/ # ALL game data lives here
│ ├── game.yml # Global settings: time scale, starting budget, term length
│ ├── metrics/
│ │ ├── global.yml # Public Trust, Budget, Regulatory Pressure, Political Stability, Legitimacy
│ │ └── district.yml # District metric templates (decay rates, recovery rates per remedy)
│ ├── districts/
│ │ ├── nap_hill.yml # Wealth, density, infrastructure quality, influence, media multiplier, stressors
│ │ ├── the_shades.yml
│ │ ├── cockbill_street.yml
│ │ ├── isle_of_gods.yml
│ │ ├── university_precinct.yml
│ │ ├── merchant_quarter.yml
│ │ ├── small_gods.yml
│ │ └── river_ankh.yml
│ ├── buildings/
│ │ ├── _types.yml # Building type templates (24 types: guild_hq, tavern, apothecary, intelligence_service, etc.)
│ │ └── instances.yml # Actual buildings: name, type, district, position, dependencies, narrative hooks
│ ├── threats/
│ │ ├── categories.yml # 6 threat categories with disruption types
│ │ ├── stressors.yml # 5 systemic stressors, starting values, amplification rules
│ │ └── events.yml # Concrete event templates: trigger conditions, impact chains, narratives
│ ├── remedies.yml # The 7 remedy types with costs, effects, narrative text
│ ├── detection.yml # Detection mechanisms, discovery times per district/building type
│ ├── narratives/
│ │ ├── headlines.yml # Layer 1: simple headline templates
│ │ └── stories.yml # Layer 2: richer story templates with variables
│ └── end_conditions.yml # Loss/neutral/escape conditions with thresholds
│
├── src/
│ ├── __init__.py
│ ├── main.py # Entry point
│ ├── config/
│ │ ├── __init__.py
│ │ └── loader.py # YAML loader, validates + merges configs into typed dataclasses; holds GameSettings
│ ├── engine/
│ │ ├── __init__.py
│ │ ├── simulation.py # Main loop: tick, advance time, check events, resolve effects
│ │ ├── clock.py # Game clock: real-time, pause, speed multiplier
│ │ ├── metrics.py # Metric state + update rules (trust decay, passive dynamics, inequality modifier)
│ │ ├── events.py # Event generation: weighted random based on stressors + neglect + event_rate_multiplier
│ │ ├── dependencies.py # Dependency graph: cascading failures, propagation (cascade_multiplier param)
│ │ ├── detection.py # Discovery time calculation, hidden vs. visible failures (discovery_speed_multiplier)
│ │ ├── remedies.py # Remedy application, delayed effects, recurrence risk
│ │ ├── narrative.py # Headline/story generation from templates + current state
│ │ └── end_check.py # End condition evaluation; respects game_duration_days override
│ ├── models/
│ │ ├── __init__.py
│ │ ├── city.py # City: holds all districts, buildings, global metrics
│ │ ├── district.py # District: local metrics, building list, stressor state
│ │ ├── building.py # Building: status, dependencies, detection state
│ │ ├── event.py # Active event: source threat, affected buildings, timeline
│ │ └── metric.py # Metric value with history (for post-game timeline)
│ └── gui/
│ ├── __init__.py
│ ├── app.py # Main window (CustomTkinter), grid layout, wires settings popup
│ ├── map_canvas.py # Map image + district tint overlays + building lamps
│ ├── dashboard.py # Right panel: global metrics, district summary, active events; gear button
│ ├── news_ticker.py # Scrolling headline feed; clickable headlines open story popup
│ ├── popups.py # Hover info, click menus (remedy selection), story popup, intro screen
│ ├── settings_popup.py # CTkToplevel settings panel: game duration, event frequency, discovery speed, cascade risk
│ ├── theme.py # Centralized colour palette, font loading (IM Fell English)
│ ├── time_controls.py # Pause/play/speed buttons + game clock; active state shown by colour
│ └── postgame.py # End-of-term reflection screen
│
├── static/
│ ├── fonts/ # Bundled IM Fell English font files
│ └── images/
│ └── ankh-morpork.png # Base map image
│
├── tests/
│ ├── test_engine/
│ │ ├── test_simulation.py
│ │ ├── test_metrics.py
│ │ ├── test_dependencies.py
│ │ ├── test_events.py
│ │ ├── test_detection.py
│ │ └── test_remedies.py
│ └── test_config/
│ └── test_loader.py
│
├── pyproject.toml
└── docs/
How the YAML configs work¶
One file per district so The Shades can be tweaked without touching Nap Hill:
# config/districts/the_shades.yml
id: the_shades
name: "The Shades"
wealth: 10 # 0-100
density: 300 # people/acre
infrastructure_quality: 3.0 # failure probability modifier (1.0 = baseline, 3.0 = 3x as likely)
political_influence: 0.2
media_attention_multiplier: 0.2
local_trust: 15
discovery_time_hours: [72, 120] # base range for non-catastrophic events
stressors:
underinvestment: extreme
social_inequality: victim # string label; used as trust-damage multiplier key
organisational_fragmentation: high
just_in_time: amplifier
narrative_hooks:
- "Fire in the Shades spreads for hours before Watch notices"
- "Shades residents block entrance demanding clean water"
Building instances reference types and districts:
# config/buildings/instances.yml
- id: mended_drum
name: "The Mended Drum"
type: tavern # references _types.yml
district: small_gods
position: [620, 300] # map canvas coordinates
dependencies:
critical: [brewery_supply, water]
operational: [energy, thieves_guild]
strategic: [transport]
narrative_hooks:
- "Mended Drum loses its licence after a riot"
- "Mended Drum brawl spills into street, breaks new fountain"
Event templates define what can happen and when:
# config/threats/events.yml
- id: pump_failure_shades
name: "Communal pump failure"
category: degradation_and_neglect
target_districts: [the_shades, cockbill_street]
target_building_types: [slum_dwelling]
probability_base: 0.02 # per tick, before multipliers
stressor_amplifiers:
underinvestment: 2.0
impact:
immediate:
- {metric: local_trust, delta: -5, scope: district}
secondary:
delay_hours: 48
effects:
- {metric: public_trust, delta: -3, scope: global}
headlines:
- "Communal pump runs dry in {district}"
stories:
- "Residents of {district} have been without clean water for {duration}. No official response has been recorded."
Engine design¶
The simulation runs on a tick-based loop (1 tick = 1 game-hour, 24 ticks/day, configurable via config/game.yml). Each tick:
Clock advances game time
Event generator rolls against probabilities (weighted by stressors, neglect, infrastructure quality, and
event_rate_multiplier)Detection checks if hidden failures become visible (
discovery_time_hourscached on first check, scaled bydiscovery_speed_multiplier)Dependency resolver propagates cascading effects (throttled to once per day per event; scaled by
cascade_multiplier)Remedy completion checker resolves events whose remedy duration has elapsed (tracked via
remedy_applied_tick)Metrics engine applies all pending effects (immediate, delayed, duration-based, passive dynamics)
Narrative engine generates headlines from templates for any new visible events
End condition checker evaluates loss/completion thresholds (respects
game_duration_daysoverride)
The engine is completely decoupled from the GUI. It exposes a simple interface:
class Simulation:
def tick(self) -> TickResult # advance one step, return structured result
def pause(self) / resume(self)
def set_speed(self, multiplier)
def apply_remedy(self, event_id, remedy_id) -> RemedyResult
def ...
TickResult bundles all tick outputs: new events, detected events, cascade events, completed remedy events, headlines,
metric changes, and end condition results.
This means the engine is testable without a GUI, and we could later swap CustomTkinter for a web frontend without touching game logic.
Key engine mechanics¶
GameSettings¶
GameSettings is a dataclass held on GameConfig in loader.py. Its four fields are applied as multipliers in engine subsystems:
Field |
Applied in |
Effect |
|---|---|---|
|
|
Scales every event’s base probability |
|
|
Scales |
|
|
Scales cascade propagation probability |
|
|
Overrides YAML |
Settings are exposed via the GUI settings popup (gear button in dashboard title bar).
Social inequality trust damage multiplier¶
District trust damage is amplified or softened based on the district’s social_inequality stressor label and the
city’s global social_inequality level:
Label |
Formula |
Effect at global level 0.7 |
|---|---|---|
|
|
+56% trust damage (Shades, Cockbill) |
|
|
+21% trust damage (Small Gods) |
|
|
−28% trust damage (Nap Hill) |
Only negative trust deltas are modified. Positive effects (recovery) are not scaled.
Passive metric dynamics¶
Each day the engine applies background drift independent of individual events:
Regulatory pressure rises with unresolved detected or responding events; falls after a quiet week
Political stability falls when trust drops below 25; recovers slowly after an incident-free week
Legitimacy recovers 0.5 points per month when stability stays above 50
Crime level rises when Watch coverage falls below 50%; suppressed slowly when above 80%
Income calculation¶
Monthly income is applied to budget and scales dynamically:
Guild fees: floored at 50%, scaled by fraction of operational guild buildings
Trade tariffs: reduced 30% per failed transport or food-supply building
Taxes: penalised when public trust drops below threshold (default 30)
Why this structure¶
Concern |
Solution |
|---|---|
“I want to tweak Nap Hill’s media multiplier” |
Edit |
“I want to add a new building” |
Add an entry to |
“I want a new threat event” |
Add to |
“I want to change how trust decays” |
Edit |
“I want to rebalance remedy costs” |
Edit |
“I want to playtest with one district” |
Point |
“I want to test the engine without a GUI” |
Run tests against |
Dependencies¶
Minimal:
customtkinter+Pillow: GUIpyyaml: config loadingPython dataclasses: typed models
pytest: testing