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>
45 KiB
rimlike — architecture
Companion to
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 indesign.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
# 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_postonext_sim_tick_posbased onaccumulator / 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. 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:
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
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).
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.
# 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:
- Priority delta — Low (0) → Critical (4) is the biggest delta. Moving items between equal-priority destinations gives 0 delta and is dropped.
- Completeness bonus — finishing a partial stack beats opening a new one. Counters the sprawl problem.
- 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_itemsand 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
- Player taps Build → Furniture → Crate.
- Designation paint mode, but single-tile (each tap places one crate ghost).
- Confirm → BuildJob queued, materials cost (e.g. 8 wood per crate).
- Construction-priority pawn picks up: haul materials → walk → work N ticks → place Container entity.
- New crate defaults: filter = all on, priority = Normal, name = "Crate #N".
- 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_itemsupdated. - 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
cellsarray. - 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; this section is data + algorithms.
MoodSystem
Per-pawn mood is a single 0–100 score recomputed each tick (or when thought modifiers change):
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
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):
# 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:
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
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
# 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. This section is data + AI plug-in.
Recipe registry (data)
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)
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)
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
# 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. This section is tech.
Equipment data
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:
# 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
# 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
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
# 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"
# 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:
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. This section is data + AI plug-in.
Ghost colony state
# 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
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)
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
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
# 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. This section is data + picker.
Prompt registry (data)
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
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
# 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
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):
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
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. 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:
# 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
# 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). 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_idlike 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_celldictionary 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
- Player taps Build → bottom-sheet drawer (Walls / Floors / Furniture / Production).
- Pick "Wall" → designation mode; finger-drag paints ghosts on Layer 3, green-if-placeable / red-if-blocked.
- Confirm → each ghost becomes a
BuildJobin a queue with material cost. - 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.
- 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.