# 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).
