# rimlike — architecture > Companion to [`memory.md`](../memory.md). This file is the **technical architecture**: pawn AI, job system, time/tick model, Godot 4 engine layout. Game design (mechanics, simplifications, vertical slice) lives in [`design.md`](./design.md). ## Pawn AI / job system Five-layer design. Slimmer than Rimworld's ThinkTree+JobGiver+WorkGiver+JobDriver, hits ~80% of the behavior. ~800–1500 lines of GDScript for full MVP, single-developer comprehensible. ### Layers (top wins) | # | Layer | Role | |---|---|---| | 1 | **Decision pipeline** | Priority-ordered "what do I do RIGHT NOW?" walk: Incapacitated → Forced → Critical need → Mental break (v2) → Work → Recreation (v2) → Idle. First match wins. | | 2 | **WorkProvider** | One per work category. Scans world for jobs in its domain, returns best for this pawn. Pawn iterates its own priority list calling `find_best_for(self)`. | | 3 | **Job + JobRunner** | Job is plain data (type, target, materials, duration, skill). JobRunner is a small state machine running the toils (steps): walk → pick up → walk → work → complete. | | 4 | **Status interrupts** | Bleeding/Tired/Hungry/Sick at threshold flag the pawn for re-decision. Each status declares its interrupt urgency (mid-walk vs. between-toils). | | 5 | **Player overrides** | Tap-pawn → "do this" issues a forced one-shot job into the pawn's queue. Runs at Layer 1 step 2. | ### MVP WorkProviders Construction · Mining · Hauling · Cooking · Plant · Doctor · Combat (later). ### Locked design defaults - **Priority levels: 5 (1 Critical / 2 High / 3 Normal / 4 When idle / Off)** — matches Rimworld + Going Medieval contract. Pawn iterates priorities 1→4, scanning display-order categories within each tier. - Within-tier order = display order (draggable, default hand-curated). - Job claiming: a Job picked up by a pawn is `claimed_by = self`; others ignore. Released on cancel/fail. - Single-pawn jobs only in MVP. No co-op hauling/building. - Skills modify duration and quality, never permission. - Interrupted runners drop carried items at the current cell — visible and recoverable in the world. ### Pseudocode shape ```gdscript # Pawn.gd (excerpt) func _on_sim_tick(dt: float) -> void: update_statuses(dt) # bleed, hunger, fatigue grow if current_job and should_interrupt_for_status(): cancel_current_job() if current_job == null: current_job = decide_next_job() if current_job: job_runner = JobRunner.new(self, current_job) if job_runner: match job_runner.tick(dt): JobResult.COMPLETE, JobResult.FAILED: _finish_job() func decide_next_job() -> Job: if has_status(Status.DOWNED): return null if not forced_queue.is_empty(): return forced_queue.pop_front() var critical = check_critical_needs() if critical: return critical return find_work_by_priority() func find_work_by_priority() -> Job: for category in WorkCategories.in_priority_order(work_priorities): var job = WorkRegistry.get(category).find_best_for(self) if job: return job return null ``` ### Open AI design questions - Skill depth — committed to **minimal**: 5 skills × 0–10, level by use, multiplicative speed/quality bonus. - Walking-speed problem — see Time / tick model below; mostly absorbed by Fast being default. - Job chaining (cook = fetch + fire + cook) — one Job with multi-step toils, not nested sub-jobs. - Animal AI — separate predatory state machine, not the pawn pipeline. - Idle behavior — punt to v2; pawns stand still or wander locally. - Save round-trip — JobRunner mid-toil state must serialize cleanly from day one. ## Time / tick model The simulation runs on a **fixed-rate sim tick** decoupled from render. Speed multipliers compress wall-clock time by stepping multiple sim ticks per render frame. ### Locked numbers | Parameter | Value | Notes | |---|---|---| | **Sim tick rate** | **20 Hz** (50 ms per tick at 1×) | Smooth enough for animation interp, low enough to be cheap on phone | | **Render rate** | 60 Hz, decoupled | Pawn sprites lerp between last and next sim tick positions | | **In-game day** | 24 in-game hours | Game uses a 24h clock; storyteller hooks at sunrise/sunset | | **1× speed** | 1 in-game minute = 1 real second | 1 day = 24 min real time at 1× — used for combat / fine work | | **Fast (default)** | 5× | 1 day ≈ **5 min real time** — matches the 5–15 min session target | | **Ultra** | 12× | 1 day ≈ 2 min real time — for skipping quiet stretches | | **Pause** | 0× | Time stops; UI fully interactive | ### Speed multiplier mechanics Multipliers drive sim tick stepping per render frame. At 60 Hz render with 20 Hz sim: - 1×: render frame queues 1 sim tick every 3 render frames (20 ticks/sec) - Fast (5×): render frame queues sim ticks at 100 ticks/sec (~1.67 ticks per render frame) - Ultra (12×): 240 ticks/sec (4 ticks per render frame) - Pause: zero ticks queued Per-tick budget at Ultra: ~4 ms to stay in 60 fps render. Achievable for our scope (3–6 pawns, < 500 entities, **80×80 MVP map; sized to a 120² ceiling**). At 80² the AStarGrid2D has 6.4k nodes; longest path ≤ 160 tiles; full A* runs sub-millisecond on a mid-range phone. At 120² (22.5k nodes) it's still well under a millisecond per query. ### Pawn movement | Speed | Real-time crossing of 80-tile map (corner-to-corner) | Feel | |---|---|---| | 1× (1 tile / 0.5 s) | ~80 s | Watchable, deliberate | | Fast (5×) | ~16 s | Snappy, default cadence | | Ultra (12×) | ~7 s | Almost teleporting | Travel times are roughly 2× the original 40-tile estimates. A wolf spawning at the map edge gives ~20 s real-time lead at Fast — meaningful warning, not tedious. This mostly absorbs the "walking-speed problem" surfaced earlier — Fast as default keeps a 5-min day from feeling like watching paint dry. ### Auto-pause triggers Default-on; player can disable individually in settings. - New threat appears (wolf seen, raid begins) - Pawn becomes Downed - Pawn dies - Storyteller fires a player-prompt event (modal) - (Optional) low health threshold, low food threshold ### Suspend / resume - **Save between sim ticks**, never mid-tick. We own the loop. - Save snapshot includes: current sim tick number, all entity state, job state (including JobRunner mid-toil), time-of-day, storyteller state. - On resume: restore, re-establish render lerp from the last sim tick. - **No background simulation.** App backgrounded = sim paused. Avoids "lost colony to a raid while at work" rage and saves battery. - On resume from a long absence: brief "you've been away X minutes" toast, then sim resumes from where it stopped. No fast-forward in MVP. ### Why 20 Hz, not 10 Hz 10 Hz works for the sim itself but creates visible jitter in pawn animation unless render-side interpolation is heavily tuned. 20 Hz halves the lerp distance, halves the perceived snap on direction changes, and the per-tick cost is still trivial. If profiling on a low-end phone shows trouble, 10 Hz with rich interp is the fallback. ### Render-vs-sim animation - Sprite/walk animations driven by **render time**, not sim ticks. They look smooth even at low sim Hz. - Pawn world-position lerps from `last_sim_tick_pos` to `next_sim_tick_pos` based on `accumulator / tick_duration`. - World tile changes (build complete, wall placed) snap on sim tick — no need to lerp tiles. ## Storage: zones, containers, hauling For player-facing rules (categories, priorities, stack sizes), see [`design.md`](./design.md). This section is data structures + AI plug-in. ### Storage destinations: a unified concept Two things accept items: **floor zones** and **containers**. The hauling AI treats them as a single pool of `StorageDestination` candidates, scored identically. Internally they share a small interface: ```gdscript interface StorageDestination: var filter: int # bitmask of 16 categories var priority: int # 0..4 (Low..Critical) var name: String func accepts(item: Item) -> bool func has_capacity_for(item: Item) -> bool func best_drop_position(from: Vector2i) -> Vector2i # tile a pawn drops at func deposit(item: Item) -> int # qty actually deposited ``` ### StockpileZone ```gdscript class StockpileZone extends StorageDestination: var id: int var name: String # "Kitchen", default "Stockpile #N" var cells: Array[Vector2i] var filter: int # bitmask of 16 categories var priority: int # CRITICAL=4, IMPORTANT=3, PREFERRED=2, NORMAL=1, LOW=0 var contents: Dictionary # cell → ItemStack (one stack per cell, one type per cell) ``` Stored as `World.zones: Array[StockpileZone]`. Rendered as a transparent colored overlay (priority-tinted) **only** when the Zones panel is open. Zero render cost otherwise. ### Container A Furniture entity. Built via Build → Furniture → Crate. One generic type in MVP (4-stack capacity). ```gdscript class Container extends Furniture, StorageDestination: var name: String # "Crate", "Crate (kitchen)" etc. var position: Vector2i # 1 tile, impassable var filter: int # bitmask of 16 categories var priority: int # 0..4 var stacks: Array[ItemStack] # max 4 in MVP ``` Pawns walk to any 4-neighbor of `position` to deposit (`best_drop_position` returns a free neighbor). Container tile itself is non-walkable. Stored as `World.containers: Array[Container]`. Inspect screen accessible by tapping the entity. ### HaulingProvider Slots into the existing 5-layer pawn AI as a `WorkProvider`. Considers BOTH zones and containers via the unified `StorageDestination` interface. ```gdscript # WorkProvider_Hauling.gd func find_best_for(pawn: Pawn) -> Job: var best: Job = null var best_score: float = -INF for item in World.items_needing_haul(): var target = World.find_best_destination_for(item) if target == null: continue var score = priority_delta(item.current_priority, target.priority) * 1000.0 \ + completeness_bonus(item, target) \ - distance(pawn.position, item.position) if score > best_score: best_score = score best = Job.new(JobType.HAUL, item, target, ...) return best func find_best_destination_for(item: Item) -> StorageDestination: var candidates: Array[StorageDestination] = [] for z in World.zones: if z.accepts(item) and z.has_capacity_for(item): candidates.append(z) for c in World.containers: if c.accepts(item) and c.has_capacity_for(item): candidates.append(c) candidates.sort_by(|d| (d.priority * -1000) + distance(item.position, d.best_drop_position(item.position))) return candidates[0] if candidates else null ``` **Score components, weighted high → low:** 1. **Priority delta** — Low (0) → Critical (4) is the biggest delta. Moving items between equal-priority destinations gives 0 delta and is dropped. 2. **Completeness bonus** — finishing a partial stack beats opening a new one. Counters the sprawl problem. 3. **Distance** — tiebreak. ### Priority flow (Rimworld semantics) Items in any storage destination at priority `p` may be re-hauled to a destination at priority > `p` if the higher destination has space and accepts the type. This is what enables "items flow Low → Critical." To avoid hot loops: - Newly-placed items go straight to the highest-priority valid destination (no flow needed). - Existing items in stored destinations are re-considered every ~5 sim seconds via a periodic dirty-marking pass: `World.mark_low_priority_items_dirty()`. - When `World.find_best_destination_for(item)` returns a higher-priority destination than the item's current one, the item joins `_dirty_items` and HaulingProvider picks it up. ### items_needing_haul A dirty-set on World, not a full scan: - When an item spawns (workbench drop, designation completes, corpse drops, etc.) → added. - When an item enters its highest-priority valid destination → removed. - Periodic priority-flow pass (every ~5 sim sec) re-marks items in lower-priority destinations if higher options have opened up. O(dirty × destinations) per tick. Trivial at our scale (worst case ~100 items × ~20 destinations). ### Hauling Job toils ``` walk_to(item.cell) → pick_up(item, capped_at_carry_capacity) → walk_to(target.best_drop_position(item.cell)) → deposit(item) → on_complete: update destination contents, mark partial-stack-still-needs-haul if applicable ``` Carry capacity capped at one stack of one type. Multi-type carry is v2. **No-destination fallback.** If `find_best_destination_for(item)` returns null on three consecutive periodic-flow passes (~15 sim seconds), the item is dropped where it lies, taken off the dirty set, and a passive "*No stockpile accepts X*" alert is queued. This prevents `_dirty_items` from cycling forever when the player has zero matching storage. The alert clears the moment a matching destination opens up (it re-enters the dirty set on the next storage-change event). **Container all-neighbors-blocked.** `best_drop_position(from)` returns the closest free 4-neighbor of the container. If all four are blocked, the hauler holds the carry and re-tries on the next pass; if still blocked after ~5 sim seconds the carry gets dropped on the hauler's current tile (same fallback as above) so it doesn't deadlock. ### Container build flow 1. Player taps Build → Furniture → Crate. 2. Designation paint mode, but single-tile (each tap places one crate ghost). 3. Confirm → BuildJob queued, materials cost (e.g. 8 wood per crate). 4. Construction-priority pawn picks up: haul materials → walk → work N ticks → place Container entity. 5. New crate defaults: filter = all on, priority = Normal, name = "Crate #N". 6. Tap to inspect/configure. ### Container operations - **Inspect** — opens UI screen (filter chips, priority picker, contents, Empty/Move/Demolish). - **Empty** — all stacks become loose Items at the crate's neighbor cells; `_dirty_items` updated. - **Move** — designate new position; pawn deconstructs + reconstructs (materials preserved). Or simpler: empty + demolish + rebuild manually. MVP: empty + demolish. - **Demolish** — like a wall: contents drop as loose items, entity removed, materials partially refunded. ### Workbench output Workbench entity has `output_cell: Vector2i` (one tile in front). Products drop there; haulers clear via the normal pipeline. ### Forbid / allow Items have `is_forbidden: bool`. HaulingProvider skips forbidden items. UI exposes via long-press. ### Zone editing operations - **Resize** — re-paint cells; update `cells` array. - **Delete** — items left behind become loose Items, added to `_dirty_items`. - **Filter change** — items now disallowed become loose, marked dirty. - **Conflict** — disallow paint where cells overlap another zone (red overlay). ### Save format Zones, containers, items, all serialize via existing per-entity save logic. ItemStack contents per-cell-or-per-container. JobRunner mid-toil state (carrying X of Y, walking to destination Z) round-trips. ## Mood, lighting, rooms, beauty, quality, cleaning The full-fidelity colonist systems. Mechanics in [`design.md`](./design.md); this section is data + algorithms. ### MoodSystem Per-pawn mood is a single 0–100 score recomputed each tick (or when thought modifiers change): ```gdscript class Thought: var id: String # "saw_corpse", "slept_well_indoors", ... var modifier: float # +/- mood points var stacks: int # for thoughts that compound var expires_at_tick: int # 0 = persistent var source_ref: Variant # the corpse, the bed, etc. class Pawn: var thoughts: Array[Thought] var mood: float # cached, recomputed when thoughts change func compute_mood(p: Pawn) -> float: var m = 50.0 # base for t in p.thoughts: m += t.modifier * min(t.stacks, MAX_STACKS_PER_THOUGHT) return clamp(m, 0, 100) ``` `MAX_STACKS_PER_THOUGHT = 5`. Caps "saw corpse" / "ate without table" / "slept on floor" pile-ups so a single thought type can't deterministically tank mood by itself; the player still loses points from variety, which matches the Rimworld feel. Thought registry (data file) drives content. Each thought entry knows: - Trigger: what game event creates it (saw_corpse from EntityProximity, slept_well from JobComplete, hungry from NeedThreshold, …) - Lifetime: persistent (driven by ongoing state) or event (decays after N hours) - Stacking rule: stack on repeat, or refresh duration Add a thought = add a registry entry + a trigger handler. ~10 lines per thought. ### Soft break behavior ```gdscript class Pawn: var break_state: BreakState = BreakState.NONE # NONE / SULKING / WANDERING var low_mood_since_tick: int = -1 func _on_sim_tick(...): if mood < 25: if low_mood_since_tick == -1: low_mood_since_tick = current_tick elif current_tick - low_mood_since_tick > THIRTY_MIN_TICKS: if break_state == BreakState.NONE: break_state = randf() < 0.5 ? SULKING : WANDERING else: low_mood_since_tick = -1 if break_state == SULKING: # override Decision pipeline: walk to nearest quiet tile, sit elif break_state == WANDERING: # walk to random nearby tiles, refuse work if mood >= 35: break_state = BreakState.NONE ``` Break state slots into the Decision pipeline at Layer 1 step 2.5 (between FORCED and CRITICAL NEED) — sulking pawns ignore non-life-threatening things; critical bleeding still pulls them out. ### LightingSystem Each `LightSource` (Furniture subtype) has `position`, `radius`, optional `color`. The system maintains a per-tile `light_level: float (0..1)`: ```gdscript # LightingSystem.gd var light_map: PackedFloat32Array # one float per tile, indexed by cell func recompute_light(affected_cells: Array[Vector2i]): for cell in affected_cells: var v = 0.0 for src in nearby_light_sources(cell, max_radius=8): var d = cell.distance_to(src.position) if d <= src.radius: v += 1.0 - (d / src.radius) light_map[index(cell)] = clamp(v, 0, 1) ``` Recomputed only when a light source is added/removed/state-changes (turned off due to fuel, etc.). Cheap. **Render side:** at night, world rendered with darkness shader; the shader samples `light_map` to brighten tiles. Phone GPU runs this comfortably for the MVP 80×80 map (and the 120² ceiling) — light recompute touches only cells inside the affected light's radius (max_radius=8 → ≤ 200 cells), not the whole map. **Mood integration:** `is_lit(cell)` returns `light_map[cell] > 0.2`. Fires "In darkness" thought for pawns in unlit tiles at night. ### RoomDetector — extends EnclosureDetector Triggered after enclosure changes; identifies discrete enclosed areas as `Room` records: ```gdscript class Room: var id: int var cells: Array[Vector2i] var furniture_in_room: Array[Furniture] var inferred_type: String # "Bedroom" / "Kitchen" / "Dining Hall" / "Hallway" / "Room" var beauty: float # cached, recomputed on furniture change var dirtiness_avg: float # cached var owners: Array[Pawn] # pawns who own a bed here func recompute_rooms(): # Flood-fill all interior cells, group into Room records # Categorize by dominant furniture ... ``` Updated when walls / furniture change. Uses the same enclosure machinery as auto-roof — just one more pass over the same data. ### Beauty score Each Furniture/Floor entity has `beauty: int` baked into its data definition. Quality multiplies it (Masterwork = ×3 beauty contribution). Room beauty is a weighted average of cell-beauty contributions with falloff for items more than 4 tiles away (so a statue helps the whole room). ### Quality system ```gdscript enum Quality { SHODDY, NORMAL, EXCELLENT, MASTERWORK, LEGENDARY } func roll_quality(skill: int, base_chance_modifier: float = 0.0) -> Quality: # Higher skill shifts the distribution var roll = randf() + skill * 0.04 + base_chance_modifier if roll > 1.4: return LEGENDARY elif roll > 1.1: return MASTERWORK elif roll > 0.7: return EXCELLENT elif roll > 0.3: return NORMAL else: return SHODDY ``` Quality multipliers (applied on item create): ``` SHODDY 0.7× NORMAL 1.0× EXCELLENT 1.25× MASTERWORK 1.5× LEGENDARY 2.0× ``` Stored as `quality: Quality` on every craftable Item entity. UI color-codes item name by quality. ### Dirtiness simulation ```gdscript # DirtinessSystem.gd var dirty_map: PackedFloat32Array # per-tile dirtiness 0..100 func _on_pawn_walk_through(cell: Vector2i, pawn: Pawn): # Pawns track in mud, blood, etc. Outdoor → indoor pawns track more. dirty_map[index(cell)] += pawn.tracking_amount * dt ``` Spike sources: combat blood, corpse near tile, spilled food. Decay: zero — dirtiness only goes down via Cleaning. ### CleaningProvider 8th WorkProvider in the existing pawn AI. Scans dirty tiles in/near rooms with priority bias toward indoor tiles. Job toils: walk → clean (timed by Manual Labor skill) → set dirtiness to 0. ## Production: workbenches, recipes, bills For player rules and recipe lists, see [`design.md`](./design.md). This section is data + AI plug-in. ### Recipe registry (data) ```gdscript class Recipe: var id: String var workbench_type: String # "smithy", "cooking_hearth", ... var inputs: Dictionary # {item_id: qty} var outputs: Dictionary # {item_id: qty}; quality rolled at create var skill: SkillType # Crafting / Cooking var duration_ticks: int var min_skill_required: int = 0 ``` Loaded from a JSON/Godot resource at startup. Adding a recipe = adding a row. ### Workbench (Furniture subtype) ```gdscript class Workbench extends Furniture: var workbench_type: String # "smithy", "carpenter_bench", ... var output_cell: Vector2i # cell in front for product drop var bills: Array[Bill] # queue var current_bill: Bill = null # actively being worked var current_pawn: Pawn = null # who's working it ``` ### Bill (queue entry) ```gdscript class Bill: var recipe_id: String var mode: BillMode # ONE_SHOT / FOREVER / UNTIL_N var count_remaining: int # for ONE_SHOT var stockpile_threshold: int # for UNTIL_N var ingredient_quality_min: Quality = Quality.SHODDY var pawn_skill_min: int = 0 var paused: bool = false func is_active() -> bool: if paused: return false if mode == ONE_SHOT: return count_remaining > 0 if mode == FOREVER: return true if mode == UNTIL_N: return World.count_in_storage(recipe.outputs) < stockpile_threshold ``` ### CraftingProvider — 9th WorkProvider ```gdscript # WorkProvider_Crafting.gd func find_best_for(pawn: Pawn) -> Job: if pawn.skills[Crafting] < required: return null # respects pawn skill var best: (Workbench, Bill) = null var best_score = -INF for bench in World.workbenches: if bench.skill != Crafting: continue for bill in bench.bills: if not bill.is_active(): continue if pawn.skills[Crafting] < bill.pawn_skill_min: continue if not ingredients_available(bill, bench): continue var score = bill_priority_in_queue(bill, bench) * 100 \ - distance(pawn.position, bench.position) if score > best_score: best_score = score best = (bench, bill) return best ? Job_Craft(bench, bill) : null ``` A separate **CookingProvider** mirrors this for cooking workbenches (hearth) using the Cooking skill. ### Crafting Job toils ``` walk_to(workbench) → for each input: walk_to(stockpile-or-crate-with-input), pick_up, walk_back, drop_at_workbench → work(duration_ticks, modified_by_skill) → on_complete: roll_quality(skill), spawn output Item with quality, place at workbench.output_cell → if mode == ONE_SHOT: count_remaining -= 1; if 0 remove bill → if mode == UNTIL_N: bill auto-deactivates next tick when threshold met → HaulingProvider clears the output via normal pipeline ``` ### Ingredient acquisition radius For MVP: pawn searches **all** stockpiles + containers globally for ingredients. Real restriction (per-bench radius) is a v2 polish item — bench can be configured to only pull from stockpiles within K tiles. ### Skill-rolled quality on output Reuses the Quality system already designed. `roll_quality(pawn.skills[recipe.skill])` returns Quality enum, multiplied into item stats on creation. UI color-codes the resulting name. ### Bill UI state — saved per workbench Bills round-trip in saves as part of the workbench entity serialization. Mid-job state (pawn currently fetching iron ingot 2 of 4) is JobRunner state, already serializable. ### Updated WorkProvider list (9) Construction · Mining · Hauling · Cleaning · **Crafting** · Cooking · Plant · Doctor · Combat ## Combat system For weapon/armor stats, hit math, and combat priority semantics, see [`design.md`](./design.md). This section is tech. ### Equipment data ```gdscript class Weapon extends Item: var weapon_type: String # "sword" / "axe" / "bow" var base_damage: int var range_tiles: int # 1 for melee, 8 for bow var attacks_per_sec: float var quality: Quality func effective_damage() -> float: return base_damage * QUALITY_MULTIPLIER[quality] class Armor extends Item: var slot: ArmorSlot # HELMET / CUIRASS / BOOTS var base_armor: int var quality: Quality func effective_armor() -> float: return base_armor * QUALITY_MULTIPLIER[quality] class Pawn: var equipped_weapon: Weapon = null var equipped_armor: Dictionary = {} # slot -> Armor var locked_assignments: Dictionary # for player-overridden auto-equip ``` ### Auto-equip system Each tick (cheap), if a Combat-enabled pawn has no weapon equipped and a valid one is in reach, queues a `Job_Equip`. Toils: walk → pick up → equip. Player long-press → "Assign to X" overrides this with a locked assignment. ### CombatSystem — auto-pause + threat detection Singleton: ```gdscript # CombatSystem.gd signal threat_appeared(threat: Entity) signal pawn_downed(pawn: Pawn) func _on_sim_tick(): var threats = scan_for_threats() # hostile pawns/animals near colonists for t in threats: if not _known_threats.has(t): emit_signal("threat_appeared", t) # Time/tick model auto-pauses on this _known_threats[t] = current_tick ``` ### Hit / damage resolution ```gdscript # CombatJob toil (per attack tick of attacker) func resolve_attack(attacker: Pawn, target: Entity): var dist = attacker.position.distance_to(target.position) if dist > attacker.equipped_weapon.range_tiles: return # out of range, attacker walks closer var hit_chance = 0.5 \ + attacker.skills[Combat] * 0.05 \ - cover_penalty(attacker.position, target.position) \ - range_penalty(dist, attacker.equipped_weapon) hit_chance = clamp(hit_chance, 0.05, 0.95) if randf() < hit_chance: var dmg = attacker.equipped_weapon.effective_damage() \ - target_total_armor(target) dmg = max(1, dmg) target.hp -= dmg if target.hp <= 0: handle_down_or_death(target) emit_signal("attack_resolved", attacker, target, true, dmg) else: emit_signal("attack_resolved", attacker, target, false, 0) # If projectile: chance to hit something else along LOS path if attacker.equipped_weapon.weapon_type == "bow": check_friendly_fire(attacker, target) ``` ### Cover detection ```gdscript func cover_penalty(attacker: Vector2i, target: Vector2i) -> float: var path = bresenham_line(attacker, target) var max_cover = 0.0 for cell in path[1..-2]: # exclude endpoints if World.has_wall_at(cell): max_cover = max(max_cover, 0.4) elif World.has_tree_at(cell): max_cover = max(max_cover, 0.2) return max_cover ``` Bresenham is microseconds at our distances. ### Downed system ```gdscript # DownedSystem.gd func handle_down_or_death(pawn: Pawn): if pawn.has_status(Status.DOWNED): # Already downed; this attack causes death pawn.die() else: pawn.add_status(Status.DOWNED) pawn.drop_weapon() World.mark_downed_pawn(pawn) # Doctors prioritize World.start_bleed_timer(pawn, BLEED_OUT_TICKS) func _on_bleed_timer_expired(pawn: Pawn): if pawn.has_status(Status.DOWNED) and not pawn.in_bed: pawn.die() # death func handle_pawn_in_bed(pawn: Pawn): if pawn.has_status(Status.DOWNED): pawn.cancel_bleed_timer() pawn.add_status(Status.RESTING) pawn.heal_over_time = true ``` ### Doctor work — Downed priority `WorkProvider_Doctor` already exists in the 9-WorkProvider list. Adds a high-priority scan: any Downed pawn within reasonable distance gets a Job_Rescue ahead of normal treatment work. Job toils: ``` walk_to(downed) → pick_up → walk_to(nearest_unoccupied_bed) → place_in_bed → optionally: walk to medical supplies, walk back, treat → reduce bleeding/infection ``` ### Combat priority — "defends if cornered" ```gdscript # When threat appears + pawn has Combat=Off: func handle_threat_for_off_pawn(pawn: Pawn, threat: Entity): var escape = find_path_away_from(pawn, threat, max_steps=5) if escape: pawn.queue_job(Job_FleeTo(escape)) else: pawn.engage(threat) # cornered, fight back with whatever is at hand ``` ### Wolf AI (MVP threat) `Wolf` is an `Animal` entity with its own state machine, separate from the pawn ThinkTree: ```gdscript states: APPROACH → ENGAGE → FLEE (at HP < 30%) → DEAD ``` Spawned at map edge by storyteller wolf event. Native attack: 6 damage, 1.0/sec. Tough hide armor: 2. Drops a butcherable corpse. ## Failure state, death, corpses, burial For mechanics see [`design.md`](./design.md). This section is data + AI plug-in. ### Ghost colony state ```gdscript # World.gd enum WorldState { ACTIVE, GHOST } var state: WorldState = ACTIVE var ghost_started_at_tick: int = 0 func _on_pawn_died(pawn: Pawn): if World.living_pawns.is_empty(): enter_ghost_state() func enter_ghost_state(): state = GHOST ghost_started_at_tick = current_tick sim.set_speed_factor(0.5) # half-time during ghost StorytellerSystem.schedule_event("wanderer_arrival", min_delay_days = 3, max_delay_days = 5) UI.show_ghost_banner("Your colony has fallen silent. Watch for travelers.") func on_recruit_wanderer(new_pawn: Pawn): state = ACTIVE sim.set_speed_factor(1.0) UI.hide_ghost_banner() ``` While in ghost state, render normally — buildings/items persist. Pawn-AI sim does nothing because no pawns. Storyteller still ticks for wanderer arrival. ### Corpse entity ```gdscript class Corpse extends Item: var pawn_record: PawnRecord # the dead pawn's full record var decay_severity: float = 0.0 # 0..100 var death_cause: String # "killed by wolf", "starvation", "bleed out", ... var death_day: int func _on_in_game_hour(): decay_severity += 33.0 / 24.0 # ~33 per day if decay_severity >= 50 and not has_status("Rotting"): add_status("Rotting") update_mood_penalty() ``` Butchering disabled when `decay_severity >= 50` (cooking-hearth recipe checks). ### PawnRecord (preserved after death) ```gdscript class PawnRecord: # not a node; data only var name: String var backstory: String var skills_at_death: Dictionary var death_day: int var death_cause: String var days_lived: int # ... statuses at death, equipment at death, etc. ``` Stored on the Corpse and on any GraveMarker built from this corpse. Pawn-detail UI for deceased pawns reads from PawnRecord. ### BurialJob A new job type. Created by HaulingProvider when: - A Graveyard zone exists (a Stockpile with ONLY the Corpses category enabled) - An unburied corpse exists in the world Toils: ``` walk_to(corpse_cell) → pick_up(corpse) → walk_to(target_grave_cell) # an empty tile in the graveyard → dig_grave (duration_ticks = 600 / manual_labor_skill_modifier) → place_grave_marker(pawn_record) → destroy(corpse) → on_complete: GraveMarker entity placed at grave_cell ``` The DigGraveJob uses Construction work category (it's manual digging + structure placement). ### GraveMarker entity ```gdscript class GraveMarker extends Furniture: var pawn_record: PawnRecord var beauty: int = 1 func on_tap() -> void: UI.show_pawn_detail(pawn_record) # deceased view ``` Persists indefinitely. Cluster of markers in a graveyard zone contributes ~+1 beauty per marker to the room/area. Tap → pawn-detail showing the deceased's record. ### Cremation pyre — Furniture + Workbench Build via Build → Furniture → Pyre. Stats: - Cost: 10 stone + 5 wood - Output cell: 1 tile in front - Recipes: just "Cremate corpse" Cremate recipe: ``` inputs: [{corpse: 1}, {wood: 5}] outputs: [] # corpse destroyed; no item produced skill: CRAFTING duration_ticks: 400 ``` Player queues a "Cremate forever" bill on the pyre; a Crafting-priority pawn auto-hauls a corpse from anywhere, performs the rite. Visual: 5-second flame + smoke FX. ### Storyteller wanderer event ```gdscript # StorytellerSystem.gd func fire_wanderer_event(): var wanderer = generate_pawn() # random name + skills UI.show_modal_event( title = "A traveler appears", body = "%s wandered to your colony. They are tired and looking for shelter." % wanderer.name, choices = [ { label = "Welcome them", on_pick = lambda(): World.add_pawn(wanderer) }, { label = "Send them away", on_pick = lambda(): pass } ] ) ``` If sent away: schedule another wanderer event 3–5 days later. The event always eventually succeeds — ghost state is recoverable, never terminal. ## Storyteller system For mechanism rules and the prompt corpus, see [`design.md`](./design.md). This section is data + picker. ### Prompt registry (data) ```gdscript class StorytellerEvent: var id: String var category: Category # NUDGE / SEASONAL / WANDERER / THREAT / DISEASE / RESOURCE / LORE / MILESTONE var title_key: String # i18n key var body_key: String # i18n key, may contain %pawn% substitution var trigger: TriggerSpec # how this fires var effect: EffectSpec # what happens var weight: float # base pool weight var cooldown_days: int # min days before this exact event can fire again ``` Loaded from a JSON registry at startup. Adding events is a row in the file. ### TriggerSpec ```gdscript class TriggerSpec: var type: TriggerType # RANDOM / STATE / TIME / SEASONAL_EVENT var min_day: int = 0 var state_predicate: Callable = null # for STATE triggers var season_filter: Array[Season] = [] var requires_pawn_count: int = 0 ``` Examples: - "First Beds" → STATE, predicate: `World.bed_count < World.living_pawns.size()`, min_day=2. - "Spring Awakens" → SEASONAL_EVENT, season=SPRING, fires-on-season-start. - "Wolves at the Edge" → RANDOM, season-weighted. ### Picker — daily roll ```gdscript # StorytellerSystem.gd func _on_daily_tick(): var pool = build_weighted_pool() var event = weighted_pick(pool) if event: fire_event(event) func build_weighted_pool() -> Dictionary: var pool = {} for ev in EVENT_REGISTRY.values(): if not ev.trigger.matches(World.state, current_day): continue # Both gates must pass: per-event AND per-category cooldown. # Per-event prevents the *same* event repeating within its individual cooldown. # Per-category enforces the design.md pacing rule (no two threats within 3 days, etc.). if recently_fired(ev.id, ev.cooldown_days): continue if recently_fired_category(ev.category, CATEGORY_COOLDOWN[ev.category]): continue var w = ev.weight * tension_modifier(ev.category) pool[ev] = w return pool func tension_modifier(category: Category) -> float: if category == THREAT and tension_score > HIGH_THRESHOLD: return 0.3 if category == THREAT and tension_score < LOW_THRESHOLD: return 2.0 return 1.0 ``` ### Tension model ```gdscript var tension_score: float = 0.0 # 0..100 # On each event fire: match event.category: THREAT: tension_score = min(100, tension_score + 30) DISEASE: tension_score = min(100, tension_score + 20) WANDERER (Welcomed): tension_score = max(0, tension_score - 10) SEASONAL: pass NUDGE: pass # Decay over time: func _on_in_game_hour(): tension_score = max(0, tension_score - 0.5) # ~12/day decay ``` Hand-tuned from playtest. ### State-trigger predicates A handful of common state predicates (Callable on `World`): ```gdscript func no_beds(): return World.bed_count < World.living_pawns.size() func no_farm(): return World.farm_zones.is_empty() func no_walls(): return World.wall_count == 0 func no_fire(): return World.light_sources.is_empty() func sick_pawn_exists(): return World.living_pawns.any(|p| p.has_status(SICK)) func is_winter(): return World.current_season == Season.WINTER ``` ### Event firing & UI dispatch ```gdscript func fire_event(ev: StorytellerEvent): var ctx = build_context(ev) # %pawn% substitution etc. match ev.category: NUDGE, SEASONAL, LORE: UI.show_ambient_banner(ev.title_key, ev.body_key, ctx) WANDERER, THREAT, DISEASE, MILESTONE: sim.auto_pause() UI.show_modal_event(ev.title_key, ev.body_key, ev.choices, ctx) RESOURCE: UI.show_ambient_banner(...) apply_effect(ev.effect, ctx) ``` Effects are applied either immediately (resource bonuses, status applications) or scheduled (wolves spawn 2 in-game hours later). ### Save format Storyteller state: tension score, recent-event log (last 30 days), per-category cooldown timers, scheduled-effect queue. Trivial to round-trip. ## Roofing, enclosure, weather For mechanics (mood, weather table, what roofs protect against) see [`design.md`](./design.md). This section covers data + algorithms. ### Roof flag on Layer 4 Layer 4 of the TileMap stores a single tile id "roofed" (or unset). The set/unset flag is the authoritative "is this tile indoors?" — no further enclosure check at sim-read time. ### EnclosureDetector — auto-roof when walls change Triggered on any Layer 2 (Wall) edit: ```gdscript # pseudocode func on_wall_change(cell: Vector2i): var affected = neighbors_within(cell, RADIUS=8) for candidate in affected: if is_already_wall_or_roofed(candidate): continue if bfs_finds_exit(candidate, max_steps=8): unset_roof(candidate) # area opened up, was roof, isn't now else: set_roof(candidate) ``` `bfs_finds_exit(start)`: flood from `start`, treating walls as blockers; return true if we reach the map edge or a No-Roof designated cell within 8 steps. Fast at our scale (8² = 64 cells worst case per BFS, called on each candidate within the affected radius — still microseconds). **Big-room feedback.** When `bfs_finds_exit` returns true *because the BFS ran out of steps without escaping or roofing* (i.e. the area is enclosed but larger than the cap), the EnclosureDetector emits a `room_too_large` signal carrying the centroid cell. The UI raises a one-shot ambient banner: "*This area is too large to roof. Split it with an interior wall.*" Threshold and exact UX wording TBD — see `memory.md` Open questions. When a wall is destroyed: candidates are re-evaluated; some may flip from roof to no-roof. ### No-Roof designation A separate persistent set on the World scene: `no_roof_cells: Dictionary[Vector2i, true]`. EnclosureDetector treats no-roof cells as map-exits during BFS, so they never become roofed. Painted via the standard designation paint mode. ### Indoor tint Render-side: indoor tiles (roof flag set) get a subtle dark-blue overlay on the floor layer. Outdoor tiles render normally. Cheap shader uniform driven by tile metadata. ### WeatherSystem — singleton ```gdscript # WeatherSystem.gd (autoload singleton) var current: Weather = Weather.CLEAR var current_season: Season = Season.SPRING func _on_sunrise(): var weights = SEASON_WEATHER_WEIGHTS[current_season] current = Weather.weighted_pick(weights) emit_signal("weather_changed", current) # Subscribers: # PawnSystem — applies Wet status to outdoor pawns at tick rate # Renderer — adjusts sky tint, rain particles # JobSystem — disables harvesting jobs in Storm ``` Storyteller can pre-empt: `WeatherSystem.set_for_next_day(Weather.STORM)` for narrative events. ### Wet status integration `Wet` is just another status in the existing pawn AI ([Pawn AI / job system](#pawn-ai--job-system)). Each tick, for each pawn: ``` if outdoors and weather in {RAIN, STORM}: wet_severity += accumulation_rate(weather) * dt elif indoors: wet_severity = max(0, wet_severity - 5 * dt_in_game_hours) ``` Mood thoughts derived from severity bands (see design.md table). ### Snow accumulation Optional polish: per-tile `snow_level: float (0..3)` on outdoor tiles in winter. Visual decal that slows movement when ≥ 3. Cheap dirty-set update at sunrise during winter. ### Save format - Roof: serialized via Layer 4 `get_used_cells_by_id` like other layers. - No-roof designations: `Array[Vector2i]`. - Weather: current state + current season + day-of-season counter. - Wet status: per-pawn severity (already in pawn save). ## Engine architecture (Godot 4) **Architectural split:** static, grid-aligned stuff is `TileMap`. Stateful, named, or multi-tile things are scene-instanced nodes (`PackedScene`). Don't try to put everything in the TileMap. ### TileMap layers | Layer | Purpose | Source | |---|---|---| | 0 Terrain | grass / dirt / stone / water | world-gen, mostly static | | 1 Floor | built wood / stone / carpet | player-placed | | 2 Wall | walls — Godot terrain autotile | player-placed | | 3 Designation | blueprint ghosts, chop / mine marks | overlay | | 4 Roof | flag tiles for "is this roofed?" | mostly invisible, sim use | | 5 Fog | optional fog-of-war / unseen | overlay | ### Entity nodes Children of a `World` node, tile-aligned positions: - **Furniture** — bed, stove, workbench, door, lamp. PackedScene per type. Tracked in a `furniture_by_cell` dictionary for fast lookup. Multi-tile shapes occupy multiple cells. - **Pawn** — `CharacterBody2D` + state-machine/utility AI. Named, persistent. - **Item** — dropped logs, stone, food, corpses. Stack qty + decay timers. - **EffectFX** — muzzle flashes, blood, smoke. Transient. ### Build flow 1. Player taps **Build** → bottom-sheet drawer (Walls / Floors / Furniture / Production). 2. Pick "Wall" → designation mode; finger-drag paints ghosts on Layer 3, green-if-placeable / red-if-blocked. 3. Confirm → each ghost becomes a `BuildJob` in a queue with material cost. 4. Idle Construction-priority pawn picks nearest job: haul materials → walk to blueprint → work N ticks → clear Layer 3, set Layer 2 (terrain autotile fixes neighbors), update pathfinder. 5. Same flow generalizes to floors, mining (terrain → log/stone drop), chopping (tree entity → log drop), deconstruction (reverse). ### Pathfinding `AStarGrid2D`, one grid the size of the map. Walkable derived from TileMap data + furniture occupancy. Update affected cells on build/destroy/door-state-change (one cell per change → O(1)). Sub-millisecond per path query at 80²; still well under a millisecond at the 120² ceiling. ### Saving Per layer, `tilemap.get_used_cells_by_id(layer)` → JSON. Plus furniture entities, pawns, items, job queue, time, storyteller state. Mid-tick suspend is safe as long as we save **between** sim ticks (we own the tick loop). JobRunner state must be serializable — design constraint from day one. ### Performance 80×80 × 5 layers = 32k tiles (MVP); 120×120 × 5 = 72k tiles (ceiling). GPU-batched in one draw call per layer regardless of cell count, so the bump is essentially free for the renderer. ~100–500 entity nodes at peak. Comfortable 60 fps on a mid-range phone with headroom. iOS export needs Mac/Xcode; Android export from Linux is fine. ## What lives elsewhere - **Game design / mechanics / simplifications** — see [`design.md`](./design.md). - **Touch UI** — see [`ui.md`](./ui.md). - **Tileset / art** — see [`art.md`](./art.md). - **Pillars and vertical slice** — see [`memory.md`](../memory.md).