rimlike/docs/architecture.md
megaproxy c5dadedab0 Bump MVP map to 80², lock world-view camera, resolve doc rot
Resolves the rimlike docs audit. Decisions made this session:

- Map: 40² → 80² for MVP slice; architecture sized to ~120² ceiling.
  Pawn count unchanged (3 start / 6 cap) — 'split-the-difference'
  sizing rather than frontier-feel scale.
- World-view camera (locked): pinch + drag-pan + double-tap-centre.
  Storyteller alerts include 'Go there' tap. No minimap, no
  follow-cam in MVP. New ui.md section captures this.
- Storyteller cooldowns: per-event AND per-category — both gates
  must pass. Resolves design.md/architecture.md disagreement.
- MAX_STACKS_PER_THOUGHT = 5 (was named-but-unset).
- Hauling no-destination fallback: drop after 3 retry passes,
  surface a passive 'No stockpile accepts X' alert.
- Container all-neighbors-blocked: hold then drop after ~5 sim s
  to avoid deadlock.
- Auto-roof big-room UX: emits room_too_large signal when BFS hits
  the ≤8-cell cap; UI surfaces a 'split with an interior wall'
  banner. Threshold + exact wording added to memory.md TODOs.

Doc rot cleaned in design.md and architecture.md: removed the stale
'8 work categories' intermediate sections (canonical 9-list lives
later in each file). Updated architecture.md perf claims for the new
map size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:45:29 +01:00

1097 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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. ~8001500 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 × 010, 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 515 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 (36 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 0100 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 35 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. ~100500 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).