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

45 KiB
Raw Blame History

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 in 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

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

  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; this section is data + algorithms.

MoodSystem

Per-pawn mood is a single 0100 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 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. 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_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.
  • PawnCharacterBody2D + 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.
  • Touch UI — see ui.md.
  • Tileset / art — see art.md.
  • Pillars and vertical slice — see memory.md.