aboutsummaryrefslogtreecommitdiff
path: root/building_guide
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-24 20:58:25 -0400
committerhistoria <[not public]>2026-06-24 20:58:25 -0400
commit9d4b799db4868bcab291b83599ff088763cd4ed6 (patch)
tree252f0fcc779acf35243983d643d6399ee2e0cd0b /building_guide
parentd25638d98fe63efdeab570ef77e6a6a97f2d7a60 (diff)
downloadthehouseoficarus-9d4b799db4868bcab291b83599ff088763cd4ed6.tar.gz
feat: new type of non-violent 'combat': work
Diffstat (limited to 'building_guide')
-rw-r--r--building_guide/bank.md68
-rw-r--r--building_guide/behaviors.md402
-rw-r--r--building_guide/conditions.md80
-rw-r--r--building_guide/construction.md223
-rw-r--r--building_guide/courses.md119
-rw-r--r--building_guide/doors.md97
-rw-r--r--building_guide/drops.md52
-rw-r--r--building_guide/hacking.md49
-rw-r--r--building_guide/hazards.md75
-rw-r--r--building_guide/hidden_objects.md92
-rw-r--r--building_guide/items.md190
-rw-r--r--building_guide/mobs.md221
-rw-r--r--building_guide/objects.md392
-rw-r--r--building_guide/quests.md103
-rw-r--r--building_guide/recipes.md277
-rw-r--r--building_guide/rooms.md209
-rw-r--r--building_guide/search.md15
-rw-r--r--building_guide/state.md8
-rw-r--r--building_guide/tips.md21
-rw-r--r--building_guide/wandering_mobs.md20
-rw-r--r--building_guide/wandering_objects.md39
21 files changed, 2752 insertions, 0 deletions
diff --git a/building_guide/bank.md b/building_guide/bank.md
new file mode 100644
index 0000000..0a5ec88
--- /dev/null
+++ b/building_guide/bank.md
@@ -0,0 +1,68 @@
+# Bank System
+
+The bank system allows players to store items permanently, freeing up inventory space.
+
+## Objects
+
+### bank_booth
+
+A bank terminal that players can interact with. Has no special behavior — the `bank` command checks for any `bank_booth` object in the room.
+
+```yaml
+id: bank_booth
+name: bank booth
+color: "226"
+description: "..."
+inroom_description: "A bank booth is set into the wall."
+```
+
+To place a bank booth in a room, add it to the room's `objects:` list:
+
+```yaml
+objects:
+ - id: bank_booth
+```
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `bank` | Opens bank interface (requires bank booth in room) |
+| `use bank` | Same as `bank` |
+
+### Bank Interface Commands
+
+| Command | Description |
+|---------|-------------|
+| `deposit <item>` | Move item from inventory to bank |
+| `deposit all` | Deposit entire inventory |
+| `withdraw <item>` | Move item from bank to inventory |
+| `browse` / `list` | Show bank contents |
+| `leave` | Exit bank interface |
+
+## Implementation
+
+### Session State
+
+`StateBank` (defined in `internal/net/server.go`) is the session state for the bank interface. Input is routed through `handleBankInput` in `internal/game/cmd_bank.go`.
+
+### Player Storage
+
+Bank items are stored in the `Player.Bank` field (`map[int]*InventorySlot`), saved in character YAML. Bank slots are sequential and reuse freed slots. There is no slot limit.
+
+### Command Dispatch
+
+- `bank` is classified as `ClassActive` in `classifyCommand()`
+- `bank` case in `executeCommand()` calls `doBank(sess)`
+- `doUse()` checks for `"bank"` or `"bank booth"` targets and routes to `doBank()`
+- `doBank()` checks for a `bank_booth` object in the current room
+- `handleBankInput()` dispatches deposit/withdraw/browse/leave commands
+
+### Data Files
+
+| File | Purpose |
+|------|---------|
+| `data/objects/bank_booth.yaml` | Bank booth object definition |
+| `data/help/bank.yaml` | Help topic for the bank system |
+
+Default bank booth locations: Town Square (room 1), Forge (room 12).
diff --git a/building_guide/behaviors.md b/building_guide/behaviors.md
new file mode 100644
index 0000000..22e97fe
--- /dev/null
+++ b/building_guide/behaviors.md
@@ -0,0 +1,402 @@
+## Behaviors
+
+Behaviors are **inline configs** placed directly inside object YAML (`gather:`, `talk:`,
+`use:`, `safespot:`) or mob YAML (`talk:`). Each section below shows the keys you can use under each behavior type.
+
+### Gather (mining, fishing, woodcutting)
+
+Mining — per-drop depletion on a rock object:
+```yaml
+id: copper_rock
+name: copper rock
+color: "178"
+gather:
+ skill: mining
+ level: 1
+ xp: 17 # XP awarded per successful gather
+ base_wait: 8 # ticks between attempts
+ tools: # requires item with matching tool_type
+ - pickaxe
+ success:
+ base: 0.40 # 40% base chance
+ per_level: 0.01 # +1% per level above requirement
+ cap: 0.95 # 95% max
+ gather_message: "You swing your pickaxe at the rock..."
+ fail_message: "You chip away but get nothing useful."
+ drops:
+ - item_id: copper_ore
+ weight: 90 # 90% chance when roll succeeds
+ depletes: true # rock becomes depleted after this drop
+ message: "You manage to mine some {178}copper ore{/}."
+ - table: gem_table # reference a shared drop table
+ weight: 10
+ depletes: false # gem drops don't deplete the rock
+ message: "You spot a glint of something valuable!"
+ respawn_timer: 50 # ticks until rock respawns
+ respawn_broadcast: "A glint of copper catches your eye from some {name}."
+```
+
+Drop messages support inline color tags: `{<0-255>}text{/}`. Use the item's color
+index to match its display color. Gradients also work: `{g:196,82}text{/}`.
+
+Non-depleting gather (fishing on a fishing spot object):
+```yaml
+id: bait_fishing_spot
+name: fishing spot
+gather:
+ skill: fishing
+ level: 1
+ xp: 10
+ base_wait: 4
+ tools:
+ - fishing_rod
+ bait: fishing_bait
+ success:
+ base: 0.30
+ per_level: 0.01
+ cap: 0.90
+ gather_message: "You cast your line into the water..."
+ fail_message: "Nothing seems to bite."
+ drops:
+ - item_id: raw_trout
+ weight: 100
+ depletes: false # never depletes
+ message: "You catch a {69}raw trout{/}!"
+```
+
+Woodcutting with shared depletion and bird's nests:
+```yaml
+id: oak_tree
+name: oak tree
+color: "113"
+gather:
+ skill: woodcutting
+ level: 15
+ xp: 37
+ base_wait: 6
+ tools:
+ - axe
+ success:
+ base: 0.40
+ per_level: 0.01
+ cap: 0.90
+ gather_message: "You swing your axe at the oak tree..."
+ fail_message: "You swing but get no logs."
+ drops:
+ - item_id: oak_logs
+ weight: 100
+ depletes: false # depletion is timer-based (deplete_timer)
+ message: "You get some {113}oak logs{/}."
+ respawn_timer: 14 # ticks until tree respawns after being cut down
+ deplete_timer: 45 # max ticks before next gather depletes (counts down while chopping)
+ nest_chance: 256 # 1/256 chance for a bird's nest on each successful gather
+ respawn_broadcast: "An {name} grows back."
+```
+
+Regular tree — always depletes on first gather, no shared timer, no nests:
+```yaml
+id: tree
+name: tree
+gather:
+ skill: woodcutting
+ level: 1
+ xp: 25
+ base_wait: 4
+ tools:
+ - axe
+ success:
+ base: 0.50
+ per_level: 0.01
+ cap: 0.95
+ gather_message: "You swing your axe at the tree..."
+ fail_message: "You swing but get no logs."
+ drops:
+ - item_id: logs
+ weight: 100
+ depletes: true # regular tree depletes on first successful gather
+ message: "You get some {107}logs{/}."
+ respawn_timer: 80
+ respawn_broadcast: "A {name} grows back."
+```
+
+#### GatherConfig Fields
+
+| Field | Type | Description |
+|---|---|---|
+| `skill` | string | Skill name for level check |
+| `level` | int | Required level to gather |
+| `xp` | int | XP per successful gather |
+| `base_wait` | float64 | Base ticks per action cycle |
+| `tools` | []string | Required tool_type list (e.g. `[pickaxe]`) |
+| `bait` | string | Required bait item (for fishing) |
+| `success` | SuccessFormula | Base + per_level, capped |
+| `gather_message` | string | Custom "You gather..." message |
+| `depleted_message` | string | Custom "The rock is depleted" message |
+| `exhausted_message` | string | Custom "The tree falls" message |
+| `fail_message` | string | Custom failure message |
+| `drops` | []DropEntry | Weighted drop entries |
+| `respawn_timer` | float64 | Ticks until depleted object respawns |
+| `respawn_broadcast` | string | Broadcast message when object respawns |
+| `deplete_timer` | float64 | Ticks for shared depletion (trees) |
+| `nest_chance` | int | 1/N chance for bird's nest alongside normal drop |
+
+#### SuccessFormula
+
+| Field | Type | Description |
+|---|---|---|
+| `base` | float64 | Base success chance (0.0-1.0) |
+| `per_level` | float64 | Chance increase per level above requirement |
+| `cap` | float64 | Maximum success chance |
+
+#### DropEntry
+
+| Field | Type | Description |
+|---|---|---|
+| `item_id` | string | Item ID to drop |
+| `table` | string | Reference to a shared drop table in `data/drops/` |
+| `weight` | int | Relative drop weight |
+| `depletes` | bool | Resource depletes on this drop |
+| `quantity` | int | Amount to drop (1-3 for random) |
+| `message` | string | Player message on drop |
+| `level` | int | Skill level required for this drop |
+| `xp` | int | Bonus XP on this drop |
+
+#### Shared depletion explained
+
+When `deplete_timer > 0`, the tree has a shared despawn timer:
+- The timer starts at `deplete_timer` max when the first player begins chopping.
+- Each tick, if anyone is chopping, the timer counts down.
+- When the timer reaches 0, the NEXT successful gather depletes the tree.
+- If no one is chopping and the tree isn't depleted, the timer ticks back UP.
+- All players chopping the same tree are interrupted when it depletes.
+
+Use `deplete_timer` for trees. Use `depletes: true` on individual drops for rocks.
+
+#### Bird's nests
+
+When `nest_chance > 0`, each successful gather has a 1/N independent chance to also drop
+a bird's nest. The nest goes to inventory (or to the ground if inventory is full).
+Use the `search` command to open nests — they roll on the `birds_nest_drop` table.
+
+#### XP drops
+
+When `xp > 0`, the gather awards XP on each successful drop. If the player's `xpdrops`
+toggle is on, the output includes the XP gain: `(+37xp wct)`.
+
+### Talk (dialog trees)
+
+Talk configs go under the `talk:` key on objects or mobs. Full conversation with
+conditions, actions, and player flag tracking:
+
+```yaml
+id: guard
+name: Guard
+talk:
+ nodes:
+ start:
+ message: "\"Halt! This area is restricted.\""
+ options:
+ - text: "\"What's behind that gate?\""
+ goto: about_gate
+ - text: "\"I have copper ore.\""
+ goto: trade_ore
+ condition:
+ has_item: copper_ore
+ - text: "\"I have a pass.\""
+ goto: has_pass
+ condition:
+ player_flag: got_pass
+ value: true
+ - text: "\"Goodbye.\""
+ end: true
+
+ about_gate:
+ message: "\"Bring me some copper ore and I'll stamp you a pass.\""
+ action:
+ set_player_flags:
+ talked_to_guard: true
+ options:
+ - text: "\"I'll be back.\""
+ end: true
+ - text: "\"I have some right here.\""
+ goto: trade_ore
+ condition:
+ has_item: copper_ore
+
+ trade_ore:
+ message: "\"Good quality ore.\" He stamps a pass and hands it to you."
+ action:
+ take_item: copper_ore
+ give_item: pass_stub
+ set_player_flags:
+ got_pass: true
+ options:
+ - text: "\"Thanks.\""
+ end: true
+
+ has_pass:
+ message: "\"Alright, I'll open the gate for you.\""
+ action:
+ set_flags:
+ gate_open: true
+ options:
+ - text: "\"Thanks.\""
+ end: true
+```
+
+#### Node action reference
+
+| Field | Effect |
+|---|---|
+| `set_flags` | Sets world flags (global, shared by all players) |
+| `set_player_flags` | Sets player-local flags (per-character, quest progress) |
+| `give_item` | Gives an item to the player's inventory |
+| `take_item` | Removes an item from the player's inventory |
+| `teleport` | Moves the player to a room ID |
+| `heal` | Restores that many hitpoints |
+| `cost` | Credits charged for the action |
+| `shop` | Opens a buy/sell shop interface (see Shop section below) |
+| `assign_task` | Assigns a random assassin task to the player based on their assassin level |
+| `skip_task` | Cancels current assassin task, costs 30 reputation, resets streak |
+| `extend_task` | Adds 50% more kills to current task, costs 30 reputation |
+| `reputation_cost` | Deducts reputation from the player's `assassin_reputation` flag (fails node if insufficient) |
+| `sawmill` | Opens sawmill plank conversion interface |
+
+All fields in a single action are processed together — you can give an item, take an item,
+set flags, and heal all in one node.
+
+Example — quest completion:
+```yaml
+action:
+ take_item: dragon_head
+ give_item: dragon_slayer_medal
+ set_player_flags:
+ dragon_quest: complete
+ dragon_slain: true
+ heal: 99
+ teleport: 1
+```
+
+#### Shop (buy/sell interface)
+
+The `shop` node action opens a dedicated buy/sell interface. The player can browse
+items, buy with credits, and sell items back. Define a shop on a talk node:
+
+```yaml
+action:
+ shop:
+ message: "What would you like to buy or sell?"
+ items:
+ - item_id: fishing_rod
+ buy_price: 10
+ sell_price: 2
+ - item_id: fishing_bait
+ buy_price: 2
+ sell_price: 0
+```
+
+| Field | Description |
+|---|---|
+| `shop.message` | Greeting shown when entering the shop |
+| `shop.items` | List of items for sale |
+| `item_id` | Item definition ID |
+| `buy_price` | Credits to buy from shop |
+| `sell_price` | Credits shop pays (0 = won't buy) |
+
+Typical shop flow: a talk node with a "Browse" option leads to a `shop` node.
+When the player leaves the shop, they return to the talk node's options.
+
+Full example — General Store:
+```yaml
+id: general_store_clerk
+name: General Store Clerk
+talk:
+ nodes:
+ start:
+ message: "\"Welcome to the General Store!\""
+ options:
+ - text: "\"I'd like to browse.\""
+ goto: shop
+ - text: "\"Goodbye.\""
+ end: true
+ shop:
+ message: "\"Take your time.\""
+ action:
+ shop:
+ message: "What would you like to buy or sell?"
+ items:
+ - item_id: fishing_rod
+ buy_price: 10
+ sell_price: 2
+ - item_id: hammer
+ buy_price: 20
+ sell_price: 5
+ options:
+ - text: "\"I'm done.\""
+ goto: start
+```
+
+Node options on the shop node are shown when the player leaves the shop.
+Use `goto: start` to loop back to the main greeting.
+
+### Object Interactions (levers, switches, gates)
+
+Interaction configs go under the `use_interactions:` key on objects.
+Entries with no `item` field are bare interactions triggered by "use"
+or "push"/"pull":
+
+```yaml
+id: iron_gate
+name: iron gate
+hidden: true
+description: "A heavy iron gate set into the north wall."
+use_interactions:
+ - condition:
+ flag: gate_open
+ value: true
+ not: true
+ message: "You push the heavy iron gate open."
+ action:
+ set_flags:
+ gate_open: true
+```
+
+Item-specific interaction:
+```yaml
+id: bookshelf
+name: bookshelf
+use_interactions:
+ - item: dusty_tome
+ message: "The bookshelf slides aside, revealing a secret passage!"
+ action:
+ set_flags:
+ secret_passage_open: true
+```
+
+### Use (crafting stations)
+
+Use configs go under the `use:` key on objects (e.g. furnaces, ranges):
+
+```yaml
+id: furnace
+name: furnace
+color: "208"
+use:
+ message: "You place the ore in the furnace..."
+ wait: 4
+ consume:
+ copper_ore: 1
+ reward:
+ item_id: copper_bar
+ quantity: 1
+ fail_message: "The ore crumbles to dust."
+ success:
+ base: 0.60
+ per_level: 0.01
+ cap: 0.95
+ skill: smithing
+ level: 1
+ xp: 15
+```
+
+---
diff --git a/building_guide/conditions.md b/building_guide/conditions.md
new file mode 100644
index 0000000..ea17ed6
--- /dev/null
+++ b/building_guide/conditions.md
@@ -0,0 +1,80 @@
+## Conditions Reference
+
+Conditions are used in talk options, exit gates, on-enter scripts, and use_interactions checks.
+
+### Simple conditions
+
+A bare `flag:` / `player_flag:` check passes when the flag is **set to a truthy
+value** (`true`, a non-zero number, a non-empty string). Add `value:` only when
+you need to match a specific value (e.g. a numeric quest stage). `not: true`
+inverts any check.
+
+```yaml
+# Check a world flag is set
+condition:
+ flag: gate_open
+
+# Check a world flag is NOT set
+condition:
+ flag: gate_open
+ not: true
+
+# Check a player flag is set
+condition:
+ player_flag: finished_tutorial
+
+# Check a player flag is NOT set (e.g. only on the first visit)
+condition:
+ player_flag: finished_tutorial
+ not: true
+
+# Match a specific (non-boolean) value
+condition:
+ player_flag: quest_stage
+ value: 3
+
+# Check if player has an item
+condition:
+ has_item: bronze_key
+
+# Check if player does NOT have an item
+condition:
+ has_item: bronze_key
+ not: true
+
+# Check if player has enough credits
+condition:
+ min_credits: 50
+```
+
+### Compound conditions
+
+All must pass:
+```yaml
+condition:
+ all_of:
+ - flag: gate_open
+ - has_item: pass_stub
+```
+
+Any one must pass:
+```yaml
+condition:
+ any_of:
+ - has_item: bronze_key
+ - has_item: iron_key
+ - player_flag: master_of_unlocking
+```
+
+Nested compounds:
+```yaml
+condition:
+ all_of:
+ - player_flag: quest_started
+ - any_of:
+ - has_item: wolf_pelt
+ - has_item: bear_pelt
+```
+
+---
+
diff --git a/building_guide/construction.md b/building_guide/construction.md
new file mode 100644
index 0000000..2c4b7ce
--- /dev/null
+++ b/building_guide/construction.md
@@ -0,0 +1,223 @@
+# Construction Skill
+
+The Construction skill allows players to process logs into planks and assemble planks into furniture at a workbench. Higher-tier logs produce more valuable planks and furniture.
+
+## Skill
+
+- Skill name: `construction`
+- Abbreviation: `con`
+- Level range: 1-99
+- XP table: RSC standard
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `construct` / `make` | Open construction menu at a workbench |
+| `construct <item>` | Build a specific item |
+| `construct <qty> <item>` | Build multiple of an item |
+
+## Objects
+
+### workbench
+
+The primary construction station. Placed in rooms to enable construction.
+
+```yaml
+id: workbench
+name: workbench
+color: "172"
+description: "A sturdy wooden workbench..."
+inroom_description: "A workbench stands against the wall..."
+```
+
+### estate_directory
+
+A terminal that shows registered homeowners and lets them enter their house. Handled entirely in Go code (`internal/game/cmd_estate_directory.go`), not via YAML behaviors.
+
+```yaml
+id: estate_directory
+name: Estate Directory
+color: "39"
+description: "A holographic terminal displaying property records..."
+inroom_description: "An estate directory terminal..."
+```
+
+When a player looks at the directory, it scans `data/players/characters/*.yaml` for the `owns_house_local_neighborhood` player flag and lists all homeowners. When a player uses or talks to the directory and owns a house, they can teleport to their house entrance room.
+
+## Tools
+
+### saw
+
+Required tool for cutting logs into planks at the workbench.
+
+```yaml
+id: saw
+name: saw
+tool_type: saw
+value: 30
+```
+
+## Items
+
+### Planks (4 tiers)
+
+| Item ID | Name | Level | Value | Made From |
+|---------|------|-------|-------|-----------|
+| `planks` | planks | 1 | 20 | logs |
+| `oak_planks` | oak planks | 15 | 40 | oak_logs |
+| `teak_planks` | teak planks | 35 | 80 | teak_logs |
+| `mahogany_planks` | mahogany planks | 50 | 120 | mahogany_logs |
+
+### Furniture (sellables)
+
+| Item ID | Level | Planks Required |
+|---------|-------|-----------------|
+| `wooden_shelf` | 4 | 2 planks |
+| `wooden_table` | 6 | 3 planks |
+| `wooden_chair` | 8 | 2 planks |
+| `oak_shelf` | 18 | 2 oak planks |
+| `oak_table` | 20 | 3 oak planks |
+| `teak_table` | 38 | 3 teak planks |
+| `mahogany_table` | 52 | 3 mahogany planks |
+
+## Recipes
+
+All recipes use `type: construction` and `station: [workbench]`. Log-to-plank recipes additionally require `tool: saw`.
+
+Example plank recipe:
+```yaml
+id: construct_oak_planks
+type: construction
+level: 15
+xp: 10
+wait: 20
+station: [workbench]
+tool: saw
+consume:
+ - items: [oak_logs]
+ quantity: 1
+output: oak_planks
+message: "You saw the oak logs into sturdy oak planks."
+```
+
+Example furniture recipe:
+```yaml
+id: construct_wooden_table
+type: construction
+level: 6
+xp: 25
+wait: 6
+station: [workbench]
+consume:
+ - items: [planks]
+ quantity: 3
+output: wooden_table
+message: "You build a sturdy wooden table."
+```
+
+## NPCs
+
+### estate_broker
+
+Located in room 16 (Construction Site). Talk behavior sells a house plot for 10 credits via `cost` + `set_player_flags`. Sets `owns_house_local_neighborhood: "local_neighborhood"` on purchase.
+
+```yaml
+id: estate_broker
+name: estate broker
+talk:
+ nodes:
+ start:
+ message: "\"Interested in a house?\""
+ options:
+ - text: "\"How much?\""
+ goto: offer
+ - text: "\"No thanks.\""
+ end: true
+ offer:
+ message: "\"10 credits for a plot.\""
+ action:
+ cost: 10
+ set_player_flags:
+ owns_house_local_neighborhood: "local_neighborhood"
+ options:
+ - text: "\"Deal!\""
+ end: true
+unique: true
+```
+
+### sawmill_operator
+
+Located in room 171 (Lumberyard). Talk behavior processes all logs in inventory into planks for a credit fee. Uses the `sawmill: true` node action which triggers `processSawmill()` in `internal/game/action_talk.go`.
+
+Fees: regular (5cr), oak (10cr), teak (20cr), mahogany (40cr).
+
+## Science Mod: Plank Make
+
+Level 86 Science utility mod that converts logs to planks at 70% of sawmill cost. Uses naturejunk, solarjunk, and scrap.
+
+```go
+{ID: "plank_make", Name: "Plank Make", Level: 86, BaseXP: 90.0,
+ JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap": 1},
+ Category: ModUtility, TargetType: "inventory"},
+```
+
+Usage: `trigger plank make` (all logs) or `trigger plank make <log type>` (specific).
+
+## Player Houses
+
+Player houses are real YAML rooms stored in `data/rooms/player_housing/`. Each house area (e.g. Local Neighborhood) has entrance + workshop rooms with unique room IDs in the 200+ range.
+
+House room files follow the same format as world rooms:
+```yaml
+id: 200
+name: "Player House: Entrance Hall"
+description: "..."
+exits:
+ south: 170
+ north: 201
+objects:
+ - id: charging_station
+```
+
+The room loader (`World.LoadRoom`) checks `data/rooms/<id>.yaml` first, then falls back to `data/rooms/player_housing/<id>.yaml`.
+
+Ownership is tracked via player flags (e.g. `owns_house_local_neighborhood: "local_neighborhood"`). The Estate Directory scans character YAML files for these flags.
+
+## Implementation
+
+### Production Integration
+
+Construction is added to `productionTypes` in `action_production.go`:
+```go
+"construction": {"construct", "constructing"},
+```
+
+This enables automatic support through the unified production system (`advanceProduction`, `startProductionFromRecipe`, etc.).
+
+### Command
+
+`cmd_construct.go` follows the same pattern as `cmd_craft.go`, using per-recipe `Station` and `Tool` checks. Station: `workbench`, Tool: `saw` (for plank recipes only).
+
+### Option
+
+`construct_all` (OptBool, default false): Auto-start construction when only one product is available.
+
+## Data File Summary
+
+| Path | Purpose |
+|------|---------|
+| `data/objects/workbench.yaml` | Workbench station object |
+| `data/objects/estate_directory.yaml` | Directory terminal object |
+| `data/items/saw.yaml` | Saw tool |
+| `data/items/planks.yaml` - `mahogany_planks.yaml` | Plank items (4 tiers) |
+| `data/items/wooden_shelf.yaml` - `mahogany_table.yaml` | Furniture items (7 items) |
+| Item YAMLs with `craft:` blocks (planks, furniture) | Construction recipes — inline on output item YAML |
+| `data/mobs/estate_broker.yaml` | Estate broker mob (talk inline) |
+| `data/mobs/sawmill_operator.yaml` | Sawmill operator mob (talk inline) |
+| `data/rooms/16.yaml` | Construction Site (updated) |
+| `data/rooms/170.yaml` | Local Neighborhood |
+| `data/rooms/171.yaml` | Lumberyard |
+| `data/rooms/player_housing/200.yaml` | Player house entrance |
+| `data/rooms/player_housing/201.yaml` | Player house workshop |
+| `data/help/construct.yaml` | Help topic |
diff --git a/building_guide/courses.md b/building_guide/courses.md
new file mode 100644
index 0000000..491e2bd
--- /dev/null
+++ b/building_guide/courses.md
@@ -0,0 +1,119 @@
+## Agility Courses
+
+Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a sequence of obstacle rooms, each with a specific verb the player must type to advance. Obstacle rooms themselves are standard room YAML files (typically in `data/rooms/agility/`).
+
+### Course YAML
+
+```yaml
+id: "vent_shaft"
+name: "Ventilation Shaft Course"
+required_level: 1
+start_room: 200
+completion_xp: 40
+obstacles:
+ - room_id: 201
+ verb: scramble
+ ticks_per_phase: 2
+ xp: 8
+ fail_damage: [1, 2]
+ messages:
+ - "You approach the corroded ventilation wall..."
+ - "You find footholds in the rusted panels and begin to climb..."
+ - "You scramble up the wall and haul yourself onto the ledge!"
+ - room_id: 202
+ verb: balance
+ ticks_per_phase: 2
+ xp: 8
+ fail_damage: [1, 2]
+ messages:
+ - "You step onto the narrow coolant pipe..."
+ - "Arms outstretched, you carefully place one foot in front of the other..."
+ - "You reach the other side of the pipe and step onto solid ground!"
+```
+
+### CourseConfig Fields
+
+| Field | Type | Description |
+| ---------------- | ------------- | ---------------------------------------------------- |
+| `id` | string | Unique course identifier |
+| `name` | string | Display name shown to players |
+| `required_level` | int | Minimum Agility level to attempt the course |
+| `start_room` | int | Hub room — teleported here on fail or lap completion |
+| `completion_xp` | int | Bonus XP awarded when a full lap is completed |
+| `obstacles` | []ObstacleDef | Ordered list of obstacles |
+
+### ObstacleDef Fields
+
+| Field | Type | Description |
+| ----------------- | -------- | --------------------------------------------------------------------------- |
+| `room_id` | int | Room ID for this obstacle |
+| `verb` | string | Command the player types to attempt it (e.g. `scramble`, `jump`, `climb`) |
+| `ticks_per_phase` | float64 | Ticks between each phase message (supports fractional via `engine.ToTicks`) |
+| `xp` | int | XP awarded for successfully completing this single obstacle |
+| `fail_damage` | [2]int | `[min, max]` damage on failure (HP clamped to minimum 1 — cannot kill) |
+| `messages` | []string | Exactly 3 strings for the 3-phase advancement system |
+
+### 3-Phase Message System
+
+Each obstacle advances through 3 phases, printing one message per phase:
+
+```
+Phase 0 (start): messages[0] printed immediately
+Phase 1 (middle): messages[1] printed — FAILURE CHECK happens here
+Phase 2 (end): messages[2] printed — XP awarded, teleport to next room
+```
+
+Failure only occurs at phase 1. Success chance is based on Agility level:
+- At required level: 70% success (30% fail)
+- Each level above reduces fail chance by 1% (down to minimum 5%)
+- Fail chance capped at 60%
+
+On failure, the player takes random damage in `[fail_damage[0], fail_damage[1]]`, is teleported back to `start_room`, and must restart the course.
+
+### Lap Counting
+
+Completing all obstacles in a course increments a lap counter stored as a player flag (`agility_laps_<course_id>`). The `completion_xp` bonus is awarded on the final obstacle only.
+
+### Obstacle Room YAML
+
+Each obstacle room should have an `on_enter` message telling the player which verb to use, and a `down` exit back to the course hub:
+
+```yaml
+id: 201
+name: "Ventilation Shaft - Corroded Wall"
+description: "A towering wall of corroded ventilation panels..."
+on_enter:
+ - message: "Type 'scramble' to climb the wall."
+exits:
+ down:
+ room: 200
+ blocked_message: ""
+```
+
+The hub room (e.g. room 200) links to the first obstacle of each course:
+
+```yaml
+id: 200
+name: "Agility Training Grounds"
+exits:
+ south: 17
+ north: 201 # Vent Shaft (level 1)
+ east: 210 # Rooftop (level 20)
+ west: 220 # Reactor (level 50)
+```
+
+### Supported Obstacle Verbs
+
+| Verb | Gerund (display) |
+| ---------- | ---------------- |
+| `scramble` | scrambling |
+| `jump` | jumping |
+| `swing` | swinging |
+| `balance` | balancing |
+| `climb` | climbing |
+| `crawl` | crawling |
+| `vault` | vaulting |
+| `leap` | leaping |
+| `slide` | sliding |
+
+Obstacle verbs are auto-registered at startup from all course YAML files. No changes to `cmd_registry.go` needed when adding new courses — any verb in a `data/courses/` file will work.
diff --git a/building_guide/doors.md b/building_guide/doors.md
new file mode 100644
index 0000000..1e9fb7a
--- /dev/null
+++ b/building_guide/doors.md
@@ -0,0 +1,97 @@
+## Global vs Player State: Door Examples
+
+### Example A: Door with a button in another room (GLOBAL)
+
+A button in room 3 opens a door in room 7. Anyone can press it. Once pressed, the door is open for everyone.
+
+**Button object** (`data/objects/door_button.yaml`):
+```yaml
+id: door_button
+name: stone button
+hidden: true
+use_interactions:
+ - condition:
+ flag: secret_door_open
+ not: true
+ message: "You press the stone button. You hear grinding stone in the distance."
+ action:
+ set_flags:
+ secret_door_open: true
+```
+
+**Room 3** — contains the button:
+```yaml
+id: 3
+name: "Button Chamber"
+description: "A small stone chamber. A button protrudes from the east wall."
+exits:
+ south: 1
+objects:
+ - id: door_button
+```
+
+**Room 7** — contains the door:
+```yaml
+id: 7
+name: "Hidden Passage"
+description: "A dusty corridor. A heavy stone door blocks the way north."
+exits:
+ south: 2
+ north:
+ room: 8
+ condition:
+ flag: secret_door_open # checks WORLD flag
+ value: true
+ blocked_message: "A heavy stone door blocks the way."
+objects:
+ - id: stone_door
+ hidden: true
+```
+
+### Example B: Key-locked door (PLAYER-LOCAL)
+
+A locked door that only opens for a player carrying the key. Each player must find their own key.
+
+**Key item** (`data/items/rusty_key.yaml`):
+```yaml
+id: rusty_key
+name: rusty key
+description: "An old iron key, still functional."
+value: 0
+stackable: false
+```
+
+**Room 5** — locked door:
+```yaml
+id: 5
+name: "Locked Storage"
+description: "A small storage room. The way east is blocked by a locked iron door."
+exits:
+ west: 2
+ east:
+ room: 6
+ condition:
+ has_item: rusty_key # checks PLAYER inventory
+ blocked_message: "The iron door is locked. You need a key."
+objects:
+ - id: iron_door
+ hidden: true
+```
+
+**Room 6** — the other side (no key needed to exit):
+```yaml
+id: 6
+name: "Storage Closet"
+description: "Shelves of dusty crates."
+exits:
+ west: 5 # exit back — no condition
+spawns:
+ - item_id: uncut_ruby
+ quantity: 1
+ respawn_ticks: 500
+```
+
+Key difference: the button door uses `flag` (shared state — one player presses, everyone benefits), the key door uses `has_item` (per-player inventory check — each player needs their own key).
+
+---
+
diff --git a/building_guide/drops.md b/building_guide/drops.md
new file mode 100644
index 0000000..e5a17ff
--- /dev/null
+++ b/building_guide/drops.md
@@ -0,0 +1,52 @@
+## Drop Tables
+
+Shared drop tables can be referenced by multiple behaviors:
+```yaml
+# data/drops/gem_table.yaml
+id: gem_table
+drops:
+ - item_id: uncut_sapphire
+ weight: 47
+ - item_id: uncut_emerald
+ weight: 16
+ - item_id: uncut_ruby
+ weight: 4
+ - item_id: uncut_diamond
+ weight: 1
+```
+
+Bird's nest drop table:
+```yaml
+# data/drops/birds_nest_drop.yaml
+id: birds_nest_drop
+drops:
+ - item_id: credits
+ weight: 50
+ quantity: 200
+ - item_id: credits
+ weight: 30
+ quantity: 500
+ - item_id: credits
+ weight: 15
+ quantity: 1000
+ - item_id: credits
+ weight: 4
+ quantity: 3000
+ - item_id: credits
+ weight: 1
+ quantity: 10000
+```
+
+Referenced from a gather behavior:
+```yaml
+drops:
+ - item_id: copper_ore
+ weight: 90
+ depletes: true
+ - table: gem_table # pulls from data/drops/gem_table.yaml
+ weight: 10
+ depletes: false
+```
+
+---
+
diff --git a/building_guide/hacking.md b/building_guide/hacking.md
new file mode 100644
index 0000000..8927b3e
--- /dev/null
+++ b/building_guide/hacking.md
@@ -0,0 +1,49 @@
+## Hacking
+
+Hacking minigames are played by jacking into terminal objects. Terminals are YAML objects that are registered in Go's `terminalDefs` map.
+
+### Terminal Object YAML
+
+```yaml
+id: terminal_basic
+name: basic terminal
+color: "40"
+description: "A battered terminal with a cracked screen. {40}[Level 1 Hacking]{/}"
+inroom_description: "A {40}basic terminal{/} hums quietly against the wall."
+```
+
+- `id` must match a key in `terminalDefs` (Go code in `internal/game/hacking.go`)
+- `name` is used for matching in the `jack` command
+- `color` controls the display color
+- `description` is shown when examining the terminal
+- `inroom_description` is shown in room listings
+- No `behavior` field — terminals are registered in Go, not YAML behaviors
+
+### Adding a New Terminal
+
+1. Create an object YAML file in `data/objects/terminal_<name>.yaml`
+2. Add an entry to `terminalDefs` in `internal/game/hacking.go`
+3. Optionally create a new minigame implementation in `internal/game/hacking_<name>.go`
+4. Place the terminal object in a room's `objects:` list
+
+### Adding a New Minigame
+
+New minigames implement the `HackingMinigame` interface:
+
+```go
+type HackingMinigame interface {
+ Init(level int) string
+ HandleInput(input string) (output string, done bool, won bool)
+ Name() string
+}
+```
+
+Optionally implement `XPBonuser` for bonus XP:
+
+```go
+type XPBonuser interface {
+ BonusXP() int
+}
+```
+
+
diff --git a/building_guide/hazards.md b/building_guide/hazards.md
new file mode 100644
index 0000000..453ca18
--- /dev/null
+++ b/building_guide/hazards.md
@@ -0,0 +1,75 @@
+## Hazards
+
+A **hazard** is an environmental threat attached to a room — a sandstorm, solar
+radiation, falling rocks, toxic spores. It rolls an "attack" against everyone in
+the room every few ticks using the **same combat formulas as a mob**: the hazard
+supplies the accuracy and max hit, and the player defends with their Defense
+level plus the equipment defense bonus selected by the hazard's attack type. No
+new combat math, no special gear — your existing armour and Defense skill are
+what keep you alive.
+
+Hazards are **shared, ID-referenced definitions** (like drop tables and modules):
+define a hazard once in `data/hazards/<id>.yaml`, then reference it from any number
+of rooms with `hazard: <id>` (see `rooms.md`). This makes one hazard reusable
+across a whole area and lets a future `examine` command resolve it by name.
+
+```yaml
+id: solar_radiation
+name: scorching solar radiation
+description: "Unfiltered sunlight pours through a gap in the dome, burning exposed skin."
+attack_type: science # stab/slash/crush/ranged/science — picks player defense + protect tech
+attack: 40 # accuracy roll level
+attack_bonus: 0 # equipment-equivalent accuracy bonus
+max_hit: 6 # maximum damage per hit
+speed: 5 # ticks between hazard rolls
+warn_message: "The dome shielding fails here — raw solar radiation pours in."
+hit_message: "Solar radiation sears you for %d damage." # optional single %d for the damage
+miss_message: "You shield your eyes and weather a wave of solar glare."
+required_item: "" # optional: an equipped item that NEGATES the hazard entirely
+```
+
+### Fields
+
+| Field | Description |
+|---|---|
+| `id` | Unique identifier (matches the filename) |
+| `name` | Display name used in warnings and default messages |
+| `description` | Flavour text (for a future `examine`) |
+| `attack_type` | `stab`/`slash`/`crush`/`ranged`/`science` — selects which player defense bonus applies and which protection tech reduces the damage (empty = `crush`) |
+| `attack` | Hazard accuracy level |
+| `attack_bonus` | Equipment-equivalent accuracy bonus |
+| `max_hit` | Maximum damage per hit |
+| `speed` | Ticks between hazard rolls (default 5 if ≤ 0) |
+| `warn_message` | Shown when warning the player about the area |
+| `hit_message` | Message on a hit; include a single `%d` to show the damage |
+| `miss_message` | Message on a miss |
+| `required_item` | If set and equipped, the hazard is fully negated for that player |
+
+### How it behaves
+
+- **Applies to everyone in the room, always** — idle, passing through, working a task,
+ or fighting a mob. Strong incentive to work fast, take cover, or leave.
+- **Tech protection applies automatically.** Because the hazard has an attack type, the
+ matching protection tech reduces its damage: Kinetic Barrier (melee types),
+ Projectile Screen (`ranged`), Neural Firewall (`science`).
+- **Safespots block all hazard damage.** A hidden player behind a safespot object takes
+ no hazard damage, regardless of hazard type. Note that doing a *melee* attack/work step
+ forces you out of cover — so ranged/science work lets you stay sheltered, melee does not.
+- **Right gear negates it.** If `required_item` is set and equipped, the hazard is skipped
+ for that player entirely. The item must exist (validated at startup).
+- **Hazards never interrupt movement.** Unlike a mob hit (which aborts a flee with
+ "Can't escape!"), a hazard hit does not touch your walk — you can keep moving out of
+ the danger zone while taking hits.
+
+### Entry warning
+
+Players walking from a **safe** room into a **hazardous** one get a `[Y/n]` confirmation
+(pressing return defaults to yes; anything other than empty/`y`/`yes` cancels). Moving
+between two hazardous rooms does not re-prompt. Players can disable the prompt with the
+account option `danger_warning` (`option danger_warning false`).
+
+### Examine (forward-compatible)
+
+Because the hazard is a shared def referenced by ID, a future `examine sandstorm` can
+resolve the current room's `hazard` ID, load the def, and render its attack type, accuracy,
+max hit and description — no hidden objects required.
diff --git a/building_guide/hidden_objects.md b/building_guide/hidden_objects.md
new file mode 100644
index 0000000..e79c026
--- /dev/null
+++ b/building_guide/hidden_objects.md
@@ -0,0 +1,92 @@
+## Hidden Objects
+
+Objects with `hidden: true` don't appear in the room's object listing. Players discover them by reading room descriptions or trying commands. The object is still fully interactable — `push gate`, `look gate`, etc.
+
+```yaml
+# data/objects/secret_lever.yaml
+id: secret_lever
+name: stone lever
+hidden: true
+description: "A cleverly concealed lever behind a loose stone."
+use_interactions:
+ - condition:
+ flag: secret_passage_open
+ value: true
+ not: true
+ message: "You pull the lever. A grinding sound echoes from the east."
+ action:
+ set_flags:
+ secret_passage_open: true
+```
+
+Room description hints at it:
+```yaml
+description: "A dusty corridor. One of the wall stones looks slightly out of place."
+```
+
+### Multiple names (`aliases`)
+
+By default an object matches its `name` (word-prefix matching) and the words in
+its `id`. Add `aliases` to accept extra names:
+
+```yaml
+id: 1002_shuttle
+name: landing craft # matches "landing", "craft", "landing craft"
+aliases: [shuttle, ship] # also matches "shuttle" and "ship"
+hidden: true
+```
+
+If a player's input matches more than one distinct object, `look` lists the
+candidates ("That's ambiguous, which one?") instead of guessing — so keep aliases
+specific enough to avoid overlap between objects in the same room.
+
+### Descriptions that react to flags
+
+An object can show different `look` text depending on the looking player's flags,
+using a `descriptions` list (first matching condition wins). This is the simplest
+way to fake per-player scenery — a crowd that becomes a queue, an NPC that
+"appears" partway through a scene — without spawning real objects or mobs.
+
+**If none of the variants match, the object is treated as absent for that
+player** — `look <it>` reports nothing is there. Combined with `hidden: true`, the
+same shared object can appear only while a player's flags warrant it (e.g. during
+an intro) and vanish on return visits.
+
+```yaml
+id: 1002_crowd
+name: crowd of people
+aliases: [people, crowd, passengers]
+hidden: true
+descriptions:
+ - condition: # after the pilot arrives
+ all_of:
+ - player_flag: boarded
+ not: true
+ - player_flag: lined_up
+ text: "The passengers have formed a single-file line."
+ - condition: # before the pilot arrives
+ all_of:
+ - player_flag: boarded
+ not: true
+ - player_flag: lined_up
+ not: true
+ text: "A couple dozen anxious passengers mill about the pad."
+ # once `boarded` is set, no variant matches -> the crowd is gone
+```
+
+A plain `description:` (with no `descriptions:` list) is always present, as before.
+
+### Safespot Objects
+
+Safespot objects should always be `hidden: true`. They only become visible in `look` when the player's effective safespot tier meets the object's `safespot.tier` requirement. This creates a natural discovery mechanic: completing quests and achievements reveals new coverage opportunities.
+
+Players can discover safespots by:
+- Reading room descriptions for hints
+- Trying `hide <object_name>` (name matching works even for hidden objects)
+- Using `look <object>` once they know the name
+- Gaining tier through progression, which reveals safespots in `look`
+
+See [Safespots](objects.md#safespots) for the full YAML format.
+
+---
+
diff --git a/building_guide/items.md b/building_guide/items.md
new file mode 100644
index 0000000..52bfa7d
--- /dev/null
+++ b/building_guide/items.md
@@ -0,0 +1,190 @@
+## Items
+
+```yaml
+id: bronze_pickaxe
+name: bronze pickaxe
+color: "178" # xterm-256 color index (0-255)
+aliases: ["pick", "pickaxe"]
+description: "A sturdy bronze pickaxe."
+value: 10
+stackable: false
+equip_slot: main_hand # optional — where it equips
+weapon_type: melee # optional — melee, ranged, or science
+attack_type: stab # stab/slash/crush/ranged/science — determines accuracy type
+stats: # optional — per-type combat bonuses
+ stab_attack: 4
+ slash_attack: -2
+ crush_attack: 2
+ strength_bonus: 3
+speed: 5 # ticks between attacks
+tool_type: pickaxe # used by gather behaviors that require "tool: pickaxe"
+tool_speed: 2 # reduces gather wait time
+requirements: # optional — skill levels needed to equip
+ accuracy: 1
+```
+
+### Equipment Stats (ItemStats)
+
+Weapons have per-type **attack bonuses** — these determine accuracy based on the weapon's `attack_type`:
+
+| Field | Description |
+|---|---|
+| `stab_attack` | Accuracy bonus for stab attacks |
+| `slash_attack` | Accuracy bonus for slash attacks |
+| `crush_attack` | Accuracy bonus for crush attacks |
+| `science_attack` | Accuracy bonus for science attacks |
+| `ranged_attack` | Accuracy bonus for ranged attacks |
+
+Armor has per-type **defense bonuses** — the mob's `attack_type` determines which defense is used:
+
+| Field | Description |
+|---|---|
+| `stab_defense` | Defense vs stab attacks |
+| `slash_defense` | Defense vs slash attacks |
+| `crush_defense` | Defense vs crush attacks |
+| `science_defense` | Defense vs science attacks (negative on metal armor) |
+| `ranged_defense` | Defense vs ranged attacks |
+
+Other damage/utility bonuses:
+
+| Field | Description |
+|---|---|
+| `strength_bonus` | Melee max hit bonus |
+| `ranged_strength` | Ranged max hit bonus (on ammo) |
+| `science_damage` | Science max hit bonus |
+| `technology_bonus` | Reduces tech battery drain rate |
+
+### Attack Type
+
+Weapons should always set `attack_type`. Common mappings:
+
+| Category | attack_type |
+|---|---|
+| Swords | slash |
+| Daggers | stab |
+| Axes | slash |
+| Pickaxes | stab |
+| Bows | ranged |
+| Unarmed | crush (default) |
+
+### Equipment Requirements
+
+Higher-tier equipment requires skill levels to equip:
+
+```yaml
+requirements:
+ accuracy: 20 # melee weapons
+ defense: 20 # armor
+ ranged: 30 # ranged weapons/armor
+```
+
+Standard tiers: bronze/iron (none), steel (5), black (10), mithril (20), adamant (30), rune (40), dragon (60).
+
+### Ranged Ammo
+
+Arrows and bolts use `ranged_strength` for damage. Accuracy comes from the bow:
+
+```yaml
+id: iron_arrow
+name: iron arrow
+stackable: true
+equip_slot: ammo
+stats:
+ ranged_strength: 10
+```
+
+### Color
+
+The `color` field accepts xterm-256 palette indices (0-255) with optional modifiers:
+
+```yaml
+color: "178" # bronze/gold
+color: "75 bold" # bold steel blue
+color: "g:196,208,226" # gradient red → orange → gold
+```
+
+In ANSI mode, extended colors (16-255) automatically downgrade to the nearest ANSI color. Use `colortable` in-game to see all 256 colors.
+
+Key item (quest token, not equippable):
+```yaml
+id: pass_stub
+name: pass stub
+description: "A crumpled slip of paper stamped with the guard's seal."
+value: 0
+stackable: false
+```
+
+---
+
+## Firemaking
+
+```yaml
+id: logs
+name: logs
+fire_level: 1 # firemaking level required
+fire_xp: 40 # XP for burning/stoking
+burn_ticks: 24 # how long the fire burns
+```
+
+## Food / Healing
+
+```yaml
+id: bread
+name: bread
+color: "222"
+description: "A fresh loaf of bread, still warm from the oven."
+value: 5
+heal_value: 5 # positive = heal, negative = damage
+eat_message: "You eat the bread. Warm and satisfying."
+```
+
+## MadeFrom — Item Combinations
+
+Define what items combine to make this item. Used by the `use` command. Combinations run through the production system — players are prompted "How many?" and the action loops with a timer.
+
+```yaml
+id: bread_dough
+name: bread dough
+color: "222"
+ticks: 2
+made_from:
+ - items: [pot_of_flour]
+ quantity: 1
+ byproducts: [empty_pot]
+ - items: [bucket_of_water, pitcher_of_water]
+ quantity: 1
+ byproducts: [empty_bucket, empty_pitcher]
+```
+
+Each entry is an ingredient slot. Multiple `items` means any of them works. The first matching item found in inventory is consumed.
+
+`ticks` controls how many game ticks each production cycle takes (default 2).
+
+### Byproducts
+
+`byproducts` is a list matching the `items` list by index. When `bucket_of_water` (items[0]) is consumed, `empty_bucket` (byproducts[0]) is returned to inventory. When `pitcher_of_water` (items[1]) is consumed, `empty_pitcher` (byproducts[1]) is returned instead.
+
+Use `""` for items that produce no byproduct:
+
+```yaml
+made_from:
+ - items: [pot_of_flour, loose_flour]
+ quantity: 1
+ byproducts: [empty_pot, ""] # pot returns empty_pot, loose flour returns nothing
+ - items: [bucket_of_water]
+ quantity: 1
+ byproducts: [empty_bucket]
+```
+
+Omit `byproducts` entirely if no items in that slot produce a byproduct:
+
+```yaml
+made_from:
+ - items: [pot_of_flour]
+ quantity: 1
+ byproducts: [empty_pot] # returns empty_pot
+ - items: [salt]
+ quantity: 1 # no byproducts field — salt is consumed entirely
+```
+
+See also `recipes.md` for station-based recipes.
diff --git a/building_guide/mobs.md b/building_guide/mobs.md
new file mode 100644
index 0000000..c1f9332
--- /dev/null
+++ b/building_guide/mobs.md
@@ -0,0 +1,221 @@
+## Mobs
+
+Basic combat mob with per-type defense bonuses:
+```yaml
+id: "man"
+name: "man"
+description: "A shabby-looking man."
+attack: 1
+strength: 1
+defense: 1
+hp: 7
+speed: 5
+aggressive: false
+respawn_ticks: 30
+attack_type: crush # stab/slash/crush/ranged/science (default: crush)
+stab_defense: 0 # per-type defense bonuses
+slash_defense: 0
+crush_defense: 0
+science_defense: 0
+ranged_defense: 0
+drops:
+ remains: "bones" # always dropped on death
+ loot:
+ - item_id: "credits"
+ weight: 98
+ quantity: 10
+ - item_id: "credits"
+ weight: 2
+ quantity: 150
+idle_descriptions:
+ - "scribbles something in a small notebook"
+ - "gazes skyward at the clouds"
+combat_descriptions:
+ - "is engaged in a fight to the death with %s"
+```
+
+### Aggressive Mobs
+
+Mobs with `aggressive: true` auto-attack players entering their room (1-tick delay). Only aggros if the player's combat level is at most double the mob's combat level. Checked on room enter and login.
+
+```yaml
+id: goblin
+name: goblin
+aggressive: true # attacks players on sight
+attack: 1
+strength: 3
+defense: 3
+hp: 12
+attack_type: slash
+attack_bonus: 3 # equipment-equivalent attack bonus for mob's attack roll
+strength_bonus: 2 # equipment-equivalent strength bonus for mob's max hit
+stab_defense: 4
+slash_defense: 3
+crush_defense: -2 # weak to crush — negative defense is valid
+science_defense: 0
+ranged_defense: 3
+```
+
+### Mob Attack/Defense Fields
+
+| Field | Description |
+|---|---|
+| `attack` | Attack level (base accuracy) |
+| `strength` | Strength level (base max hit) |
+| `defense` | Defense level (base defense) |
+| `hp` | Hit points |
+| `ranged` | Ranged level (for ranged/science mobs) |
+| `science` | Science level |
+| `attack_bonus` | Equipment-equivalent attack bonus (added to attack roll) |
+| `strength_bonus` | Equipment-equivalent strength bonus (added to max hit) |
+| `attack_type` | Determines which player defense is used against this mob |
+| `stab_defense` through `ranged_defense` | Per-type defense bonuses |
+| `weakness` | Elemental weakness (solar/hydro/eco/bio) — for future Science combat |
+| `size` | Mob size for safespot blocking: small/medium/large/massive (default: medium) |
+
+### Protected/Unique Mobs
+
+Mob with talk behavior — can be talked to via `talk:` inline config:
+```yaml
+id: "guard"
+name: "Guard"
+talk: # inline talk config (no separate behavior file)
+ nodes:
+ start:
+ message: "\"Halt! Who goes there?\""
+ options:
+ - text: "\"Just passing through.\""
+ end: true
+unique: true # displays as "Guard" not "a guard"
+protected: true # cannot be attacked
+attack: 5
+strength: 5
+defense: 5
+hp: 30
+speed: 5
+aggressive: false
+respawn_ticks: 60
+attack_type: slash
+attack_bonus: 8
+strength_bonus: 6
+stab_defense: 12
+slash_defense: 15
+crush_defense: 10
+science_defense: -5
+ranged_defense: 12
+idle_descriptions:
+ - "scans the area with a watchful eye"
+ - "adjusts the grip on his weapon"
+```
+
+Note: `wander_rooms` and `wander_interval` are NOT set on the mob definition. Wander config
+is per-instance in the room YAML (see Rooms > Mobs section above).
+
+### Stealable Mobs
+
+Mobs can be stealable via the `steal` command. On failure, the mob turns hostile and attacks.
+
+```yaml
+id: farmer
+name: Farmer
+steal_table: farmer_steal # Drop table for steal loot
+steal_level: 10 # Required thieving level
+steal_xp: 15 # XP per successful steal
+steal_speed: 4 # Ticks per steal attempt
+```
+
+| Field | Description |
+|---|---|
+| `steal_table` | Drop table ID for loot when stealing |
+| `steal_level` | Required thieving level |
+| `steal_xp` | XP awarded per successful steal |
+| `steal_speed` | Ticks per steal attempt (base wait) |
+
+### Assassin Mobs
+
+Assassin (Slayer) mobs restrict combat by assassin level and introduce finishing blows and protective equipment:
+
+```yaml
+id: slug
+name: slug
+assassin_level: 1 # required assassin level to attack
+finishing_blow: salt # item required to kill (mob stays at 1 HP otherwise)
+attack: 3
+strength: 3
+defense: 1
+hp: 15
+speed: 6
+drops:
+ remains: slug_mucus
+ loot:
+ - item_id: credits
+ weight: 100
+ quantity: 15
+```
+
+Damage-without mobs deal 1.5x damage unless the player has the specified item equipped:
+```yaml
+id: drone
+name: drone
+assassin_level: 15
+damage_without: insulated_gloves # 1.5x damage without this item equipped
+attack: 15
+strength: 14
+defense: 12
+hp: 45
+speed: 4
+aggressive: true
+```
+
+| Field | Description |
+|---|---|
+| `assassin_level` | Required assassin skill level to attack this mob |
+| `finishing_blow` | Item ID required to kill this mob (mob stays at 1 HP until used via `use <item> on <target>`) |
+| `damage_without` | Item ID for protection — mob deals 1.5x damage if player doesn't have it equipped |
+
+Mobs without these fields work normally (assassin_level defaults to 0, finishing_blow and damage_without default to empty).
+
+### Task Mobs (Worksites)
+
+A task mob is a non-violent "worksite" — build a solar panel, survey flora, prospect a rock — that reuses the entire combat engine. Set `kind: task`. Mechanically its `hp` is a hidden work pool that drains to 0 to **complete** the work; the player sees a progress bar filling toward 100%. A task mob **never attacks back** — the danger (if any) comes from a room `hazard:` (see `hazards.md`). Use the `work` command (or `attack`) on it.
+
+```yaml
+id: solar_panel_frame
+name: solar panel frame
+kind: task # "" / "combat" (default) or "task"
+verb: assemble # flavor verb shown in messages (default: "work on")
+progress_noun: assembly # flavor noun in the progress line (default: "work")
+complete_message: "You bolt the final panel into place and the array hums to life!"
+hp: 40 # units of work to complete (NOT a health bar)
+defense: 10 # base difficulty
+speed: 5
+aggressive: false # task mobs do not fight; this is ignored
+respawn_ticks: 40 # the worksite replenishes after this many ticks
+attack_type: crush
+# Per-type defenses become METHOD EFFICIENCY: low = that approach works well.
+crush_defense: 0 # hammering panels in: efficient
+stab_defense: 40 # stabbing a frame: useless
+slash_defense: 40
+ranged_defense: 10 # a rivet gun / scanner (ranged weapon): works
+science_defense: 10 # an analysis deck (science weapon): works
+drops: # the "payment" for the labour — same drop system as combat
+ loot:
+ - item_id: credits
+ weight: 95
+ quantity: 40
+ - item_id: coal
+ weight: 5
+ quantity: 1
+```
+
+Key points:
+
+- **All combat styles work.** Melee, ranged (consumes ammo as "supplies") and science decks (consume junk) all apply progress. The per-type `*_defense` values steer which method is *efficient* without forbidding any — a rock might have low `crush_defense` but high `stab_defense`.
+- **XP and buffs are identical to combat** — driven by your attack style (accurate/aggressive/defensive/balanced) and split into the combat skills. This is a non-violent way to train Attack/Strength/Defense/Ranged/Science/Hitpoints.
+- **No special equipment.** Task mobs are designed around the weapons and bonuses you already have. There is no separate "tool" equipment set.
+- **Progress decays exactly like inverted mob regen.** A task you stop working on slowly loses progress over time (the same regen that heals combat mobs, displayed inverted).
+- **One worker at a time**, the same 1-v-1 lock as combat.
+
+---
+
+
diff --git a/building_guide/objects.md b/building_guide/objects.md
new file mode 100644
index 0000000..427968a
--- /dev/null
+++ b/building_guide/objects.md
@@ -0,0 +1,392 @@
+## Objects
+
+Objects interact with the world through **inline behavior configs** under
+`gather:`, `talk:`, or `use:` keys. There is no separate behavior
+directory — everything goes directly in the object's YAML.
+
+Gathering object (mining):
+```yaml
+id: copper_rock
+name: copper rock
+color: "178" # xterm-256 color index (0-255)
+gather:
+ skill: mining
+ level: 1
+ xp: 17
+ base_wait: 8
+ tools:
+ - pickaxe
+ success:
+ base: 0.40
+ per_level: 0.01
+ cap: 0.95
+ gather_message: "You swing your pickaxe at the rock..."
+ fail_message: "You chip away but get nothing useful."
+ drops:
+ - item_id: copper_ore
+ weight: 90
+ depletes: true
+ message: "You manage to mine some {178}copper ore{/}."
+ - table: gem_table
+ weight: 10
+ depletes: false
+ message: "You spot a glint of something valuable!"
+ respawn_timer: 50
+ respawn_broadcast: "A glint of copper catches your eye from some {name}."
+```
+
+Tree object (woodcutting with shared depletion):
+```yaml
+id: oak_tree
+name: oak tree
+color: "113"
+gather:
+ skill: woodcutting
+ level: 15
+ xp: 37
+ base_wait: 6
+ tools:
+ - axe
+ success:
+ base: 0.40
+ per_level: 0.01
+ cap: 0.90
+ gather_message: "You swing your axe at the oak tree..."
+ fail_message: "You swing but get no logs."
+ drops:
+ - item_id: oak_logs
+ weight: 100
+ depletes: false
+ message: "You get some {113}oak logs{/}."
+ respawn_timer: 14
+ deplete_timer: 45
+ nest_chance: 256
+ respawn_broadcast: "An {name} grows back."
+```
+
+Color accepts xterm-256 indices with optional modifiers (`bold`, `dim`, `underline`)
+and gradients (`g:196,82`). In ANSI mode, extended colors downgrade to the nearest
+ANSI color.
+
+Object interaction (gate, lever — uses `use_interactions:` key):
+```yaml
+id: iron_gate
+name: iron gate
+hidden: true
+description: "A heavy iron gate set into the north wall."
+use_interactions:
+ - condition:
+ flag: gate_open
+ value: true
+ not: true
+ message: "You push the heavy iron gate open."
+ action:
+ set_flags:
+ gate_open: true
+```
+
+Decorative object (no behavior keys — just a name/description):
+```yaml
+id: lumby_fountain
+name: town fountain
+description: "Clear water sparkles in the sunlight."
+```
+
+Talk object (NPC conversations — uses `talk:` key):
+```yaml
+id: tool_shed
+name: Tool Shed
+description: "A small shed with an open window."
+talk:
+ nodes:
+ start:
+ message: "\"Welcome to the tool shed!\""
+ options:
+ - text: "\"What do you have?\""
+ goto: shop
+ - text: "\"Goodbye.\""
+ end: true
+```
+
+See the [Talk section of behaviors](behaviors.md#talk-dialog-trees) for the full
+dialog tree format. See [Stealable Objects](#stealable-objects) for theft mechanics.
+
+---
+
+## Use Interactions
+
+Objects can define `use_interactions` to handle when a player uses a specific item on the object. Each interaction can check conditions, show a message, and execute actions — using the same condition and action primitives as the talk system.
+
+Entries are checked top-to-bottom; the first match with a passing condition wins.
+
+Simple message (no action):
+```yaml
+id: anvil
+name: anvil
+use_interactions:
+ - item_id: silver_bar
+ message: "You should use this with a mold at a furnace."
+ - item_id: gold_bar
+ message: "You should use this with a mold at a furnace."
+```
+
+Puzzle interaction (take item, set flag):
+```yaml
+id: crystal_slot
+name: crystal slot
+use_interactions:
+ - item_id: crystal_key
+ condition:
+ flag: crystal_inserted
+ message: "The crystal key is already in the slot."
+ - item_id: crystal_key
+ message: "You insert the crystal key into the slot. It clicks into place."
+ action:
+ take_item: crystal_key
+ set_flags:
+ crystal_inserted: true
+```
+
+Quest item exchange:
+```yaml
+use_interactions:
+ - item_id: ancient_scroll
+ condition:
+ player_flag: quest_started
+ message: "You place the scroll on the pedestal. The door rumbles open!"
+ action:
+ take_item: ancient_scroll
+ set_player_flags:
+ quest_complete: true
+ set_flags:
+ temple_door_open: true
+```
+
+### UseInteraction Fields
+
+| Field | Type | Description |
+| ----------- | ---------- | ------------------------------------------------ |
+| `item_id` | string | Item ID that triggers this interaction |
+| `condition` | Condition | Optional condition (same as exits/talk/use_interactions) |
+| `message` | string | Message shown to the player |
+| `action` | NodeAction | Optional actions (same as talk node actions) |
+
+### Available Actions (same as talk)
+
+| Field | Type | Description |
+| ------------------ | -------------- | ---------------------------------- |
+| `set_flags` | map[string]any | Set world flags (shared) |
+| `set_player_flags` | map[string]any | Set player flags (per-character) |
+| `give_item` | string | Give an item to inventory |
+| `take_item` | string | Remove an item from inventory |
+| `teleport` | int | Move player to a room ID |
+| `heal` | int | Restore hitpoints |
+
+### Available Conditions (same as exits/talk)
+
+| Field | Description |
+| ------------- | -------------------------------- |
+| `flag` | World flag check |
+| `player_flag` | Per-character flag check |
+| `has_item` | Inventory item check |
+| `value` | Expected value for flag checks |
+| `not` | Invert the condition |
+| `all_of` | All sub-conditions must pass |
+| `any_of` | Any sub-condition must pass |
+
+---
+
+## Stealable Objects
+
+Objects can be stealable via the `steal` command. These use the same drop table system as mobs and searches.
+
+```yaml
+id: market_stall
+name: Market Stall
+description: "A wooden stall piled with food and sundries."
+steal_table: market_stall_steal
+steal_level: 5
+steal_xp: 12
+steal_speed: 5
+guard_mob: guard
+```
+
+| Field | Description |
+| ------------- | ------------------------------------------------ |
+| `steal_table` | Drop table ID for loot when stealing |
+| `steal_level` | Required thieving level |
+| `steal_xp` | XP awarded per successful steal |
+| `steal_speed` | Ticks per steal attempt (base wait) |
+| `guard_mob` | Mob def ID that guards this object (watches it) |
+
+When `guard_mob` is set, a mob with that def ID in the same room watches the object
+on a tick-based cycle (8 ticks watching, 4 ticks looking away). While the guard is
+watching, steal success chance is halved and failures trigger a confrontation dialog.
+
+Use the `sneak` command to see guard watch state changes in real time.
+
+---
+
+## Safespots
+
+Safespot objects provide cover that blocks melee attacks. Players use `hide <object>` to
+crouch behind them and attack with ranged or science weapons. Melee attacks force the player
+out of cover.
+
+```yaml
+id: rock_outcrop
+name: rock outcrop
+color: "248"
+hidden: true
+inroom_description: "A jagged rock outcrop juts from the floor."
+description: "A large, jagged rock formation providing natural cover."
+safespot:
+ tier: 2
+ max_block_size: large
+ max_occupants: 3
+ unsafe_chance: 0.008
+ decay_ticks: 500
+ decay_chance: 0.01
+ respawn_on_hide: false
+ respawn_ticks: 300
+ levels:
+ - message: "The rock formation stands solid."
+ degrade_message: "The rock structure begins crumbling!"
+ - message: "The rock outcrop is cracked and worn."
+ degrade_message: "A few jagged bits are all that's left of the rock outcrop!"
+ - message: "Only a few jagged rocks remain."
+ degrade_message: "The rock outcrop crumbles to nothing!"
+```
+
+Safespot objects should be `hidden: true`. They never appear in room descriptions — players
+discover them through quest guidance, `inroom_description` text in the room's YAML, or by
+examining objects directly with `look <name>`.
+
+### SafespotConfig Fields
+
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `tier` | int | 0 | Required safespot tier to use this object |
+| `max_block_size` | string | `""` (all) | Largest mob size blocked: small/medium/large/massive |
+| `max_occupants` | int | 0 | Max players that can hide behind this object (0 = unlimited) |
+| `unsafe_chance` | float64 | 0 | Per-tick chance (0.0-1.0) to be forced out of cover |
+| `decay_ticks` | float64 | 0 | Guaranteed ticks before object degrades one level |
+| `decay_chance` | float64 | 0 | Per-tick random chance (0.0-1.0) to degrade one level |
+| `respawn_on_hide` | bool | false | If true, hiding behind an empty safespot resets it to max level. Only triggers when no occupants are present. |
+| `respawn_ticks` | float64 | 0 | Ticks until a destroyed safespot respawns (0 = never respawns — object is deleted from the room) |
+| `levels` | []SafespotLevel | required | At least one level entry |
+
+### SafespotLevel Fields
+
+| Field | Type | Description |
+|---|---|---|
+| `message` | string | Shown to the player when the safespot is at this level |
+| `degrade_message` | string | Broadcast to the room when the safespot degrades FROM this level |
+
+Levels are ordered from highest to lowest. The first entry is the safespot's best state;
+the last entry is its weakest state before destruction. When the last level degrades,
+the safespot is destroyed.
+
+### Three Safespot Patterns
+
+The combination of `respawn_on_hide` and `respawn_ticks` creates three distinct use cases:
+
+**1. Standard Safespot (always available)**
+
+```yaml
+safespot:
+ respawn_on_hide: true
+ respawn_ticks: 0
+ levels:
+ - message: "You crouch behind the rock formation, using it as cover."
+```
+
+The safespot starts at full health each time a player hides behind it (first occupant only —
+subsequent players don't reset the level). Ideal for skilling areas and general exploration.
+Since `respawn_ticks: 0`, a destroyed safespot would be deleted, but with a single level and
+no decay, it never degrades. Simple and permanent.
+
+**2. Regenerating Safespot (timer-based respawn)**
+
+```yaml
+safespot:
+ respawn_on_hide: false
+ respawn_ticks: 300
+ levels:
+ - message: "The rock formation stands solid."
+ degrade_message: "The rock structure begins crumbling!"
+ - message: "The rock outcrop is cracked and worn."
+ degrade_message: "A few jagged bits are all that's left!"
+ - message: "Only a few jagged rocks remain."
+ degrade_message: "The rock outcrop crumbles to nothing!"
+```
+
+Degradation persists between hides — a player who leaves and re-hides finds the
+safespot at its previous level. After total destruction, a timer (`respawn_ticks`)
+counts down, then the safespot reforms at full health. Suits areas with heavy use
+where a temporary respite is valuable.
+
+**3. Disposable Safespot (boss fight cover)**
+
+```yaml
+safespot:
+ respawn_on_hide: false
+ respawn_ticks: 0
+ levels:
+ - message: "The barricade offers solid cover."
+ degrade_message: "The barricade splinters under the assault!"
+ - message: "The barricade is splintering badly."
+ degrade_message: "The barricade shatters completely!"
+```
+
+Degradation persists between hides. When the last level degrades, the safespot is
+**deleted from the room entirely** — it does not respawn. Perfect for boss encounters
+where a room script places temporary cover that the party consumes during the fight.
+Once destroyed, it's gone for good (until the boss room re-instantiates it).
+
+### Mob Size
+
+Mobs must have a `size` field for safespot blocking to work:
+
+```yaml
+# data/mobs/cow.yaml
+size: small
+
+# data/mobs/moss_giant.yaml
+size: large
+```
+
+Size ordering: `small` < `medium` < `large` < `massive`. Defaults to `medium` when absent.
+A safespot with `max_block_size: large` blocks mobs of size small, medium, and large,
+but not massive.
+
+### Progression
+
+Players gain safespot tier by completing quests and combat achievements, which set
+player flags via talk node actions:
+
+| Flag | Source | Tier bonus |
+|---|---|---|
+| `quest_animal_magnetism` | Quest completion | +1 |
+| `quest_dragon_slayer` | Quest completion | +1 |
+| `achieve_medium_combat` | Combat achievement | +1 |
+| `achieve_hard_combat` | Combat achievement | +2 |
+
+### Combat Integration
+
+- Melee mobs (attack_type: stab/slash/crush) cannot initiate aggro or land hits on
+ safespotted players if the mob's size is within the safespot's `max_block_size`.
+- Ranged and science mobs (attack_type: ranged/science) ignore safespots entirely.
+- If a safespotted player attacks with melee, they automatically leave cover.
+- Hiding in combat takes 4 ticks; if the mob hits during this time, the hide fails.
+- Hiding out of combat takes 1 tick.
+
+### Option: safespot_alert
+
+Players can customize the message shown when forced out of a safespot:
+
+ option safespot_alert "{196 bold}** DANGER **{/} Cover blown!"
+Default: `"{196 bold}** Your safespot has been compromised! **{/}"`
+
+---
+
diff --git a/building_guide/quests.md b/building_guide/quests.md
new file mode 100644
index 0000000..b5f18fa
--- /dev/null
+++ b/building_guide/quests.md
@@ -0,0 +1,103 @@
+## Complete Quest Example: "Clear the Rats"
+
+### 1. Quest giver mob (`data/mobs/quest_giver.yaml`)
+```yaml
+id: "quest_giver"
+name: "Elder"
+unique: true
+protected: true
+talk:
+ nodes:
+ start:
+ message: "\"Rats! Rats everywhere in the cellar. Clear them out and I'll reward you.\""
+ options:
+ - text: "\"I'll handle it.\""
+ goto: accept_quest
+ condition:
+ player_flag: rat_quest
+ not: true
+ - text: "\"I killed the rats.\""
+ goto: turn_in
+ condition:
+ player_flag: rat_quest
+ value: started
+ - text: "\"Goodbye.\""
+ end: true
+
+ accept_quest:
+ message: "\"Good lad. The cellar is west of here. Come back when they're dead.\""
+ action:
+ set_player_flags:
+ rat_quest: started
+ options:
+ - text: "\"On my way.\""
+ end: true
+
+ turn_in:
+ message: "\"You did it! The village owes you a debt. Here — take this.\""
+ action:
+ give_item: rusty_sword
+ set_player_flags:
+ rat_quest: complete
+ heal: 10
+ options:
+ - text: "\"Thanks!\""
+ end: true
+attack: 1
+strength: 1
+defense: 1
+hp: 20
+speed: 5
+aggressive: false
+idle_descriptions:
+ - "mutters about the rat infestation"
+```
+
+Talk configs are inline on the mob under the `talk:` key. No separate behavior file needed.
+
+### 2. Rat mobs (`data/mobs/rat.yaml`)
+```yaml
+id: "rat"
+name: "giant rat"
+attack: 2
+strength: 1
+defense: 1
+hp: 3
+speed: 4
+aggressive: true
+respawn_ticks: 60
+drops:
+ remains: "rat bones"
+```
+
+### 3. Cellar room (`data/rooms/20.yaml`)
+```yaml
+id: 20
+name: "Cellar"
+description: "A damp, dark cellar. The floor scuttles with movement."
+exits:
+ east: 1
+mobs:
+ - "rat"
+ - "rat"
+ - "rat"
+ - "rat"
+ - "rat"
+```
+
+### 4. Room 1 with quest giver (`data/rooms/1.yaml`)
+```yaml
+id: 1
+name: "Town Square"
+description: "Cobblestone paths lead in all directions. The Elder stands near the fountain."
+exits:
+ west: 20
+ north: 2
+mobs:
+ - "quest_giver"
+objects:
+ - id: lumby_fountain
+```
+
+---
+
diff --git a/building_guide/recipes.md b/building_guide/recipes.md
new file mode 100644
index 0000000..450ded9
--- /dev/null
+++ b/building_guide/recipes.md
@@ -0,0 +1,277 @@
+# The House of Icarus - World Building Guide
+
+## Crafting
+
+Crafting information lives on the **output item's YAML file** via the `craft:` block. There is no separate recipe directory. To find out how to make `bronze_dagger`, open `data/items/equipment/bronze_dagger.yaml` and look for the `craft:` block.
+
+### Craft YAML Reference
+
+| Field | Type | Description |
+| -------------- | -------------- | ----------------------------------------------- |
+| `type` | string | Craft type (pharmacy, smithing, cooking, smelting, crafting, fletching, construction, clean, or "" for skill-less) |
+| `skill` | string | Skill checked (defaults to type if omitted) |
+| `level` | int | Required skill level |
+| `xp` | int | XP awarded on success |
+| `wait` | float64 | Ticks per craft cycle |
+| `station` | []string | Station object IDs required (optional) |
+| `tool` | string | Required tool_type (optional) |
+| `consume` | []ConsumeEntry | Ingredients consumed (see below) |
+| `output_qty` | int | Quantity produced (default 1, for stackables) |
+| `fail` | string | ItemID produced on failure (optional) |
+| `message` | string | Success message per cycle (optional, see message defaults) |
+| `fail_message` | string | Failure message (optional, empty = silent fail) |
+| `start_message`| string | Message when action begins (optional, see message defaults) |
+| `end_message` | string | Message when action completes (optional, see message defaults) |
+| `steps` | []CraftStep | Multi-step messages at tick intervals (optional)|
+| `success` | SuccessFormula | Optional skill check formula (see Behaviors) |
+
+The output is the item itself — no `output` field needed. The item ID IS the craft ID.
+
+### Message Variables
+
+All message fields (`message`, `fail_message`, `start_message`, `end_message`, and `steps[].message`) support variables that expand to colorized item names:
+
+| Variable | Expands to |
+| -------- | ----------------------------------------------- |
+| `%n` | Output item name (colored, e.g. "stim potion") |
+| `%i1` | 1st consume entry's matched item (colored) |
+| `%i2` | 2nd consume entry's matched item (colored) |
+| `%b1` | 1st byproduct item name (colored, success only) |
+| `%b2` | 2nd byproduct item name (colored, success only) |
+
+Numbering follows YAML consume/byproduct order. If a consume entry has multiple alternative items (`items: [a, b]`), the variable expands to whichever the player actually possesses, colored with that item's `color:` field.
+
+Variables work alongside inline color tags (`{196}text{/}`) which are expanded after variable substitution.
+
+### Message Defaults
+
+If a message field is omitted from the YAML, the game uses a skill-level default based on the craft `type`:
+
+| Type | Default `message` | Default `start_message` |
+|------|-------------------|------------------------|
+| `cooking` | `"Cooked to perfection. %n looks great!"` | `"You start cooking %i1."` |
+| `smelting` | `"You remove a white hot %n!"` | `"You place the %i1 into the furnace."` |
+| `smithing` | `"You smith a %n."` | `"You begin smithing %i1."` |
+| `crafting` | `"You craft a %n."` | `"You begin crafting %i1."` |
+| `fletching` | `"You fletch a %n."` | `"You begin fletching %i1."` |
+| `pharmacy` | `"You mix a %n."` | `"You start mixing %i1."` |
+| `construction` | `"You construct a %n."` | `"You begin constructing %i1."` |
+| `clean` | `"You clean the %i1."` | `"You begin cleaning herbs."` |
+| (unset) | `"You produce %n."` | `"You start <verb> %i1."` |
+
+Any of these can be overridden per-item by setting the field in YAML. For skills that never fail (smithing, crafting, fletching, pharmacy, construction), `fail_message` defaults to empty — no message is shown on failure.
+
+### CraftStep — Multi-Step Messages
+
+Optional interim messages fired at specific tick offsets during the craft cycle. The `tick` is the number of ticks elapsed since the start of the cycle.
+
+```yaml
+craft:
+ type: ""
+ wait: 6
+ consume:
+ - items: [map_piece_1]
+ quantity: 1
+ - items: [map_piece_2]
+ quantity: 1
+ - items: [map_piece_3]
+ quantity: 1
+ steps:
+ - tick: 1
+ message: "You try to puzzle how the pieces fit together."
+ - tick: 3
+ message: "Aha, this edge lines up here!"
+ message: "You assemble the map!"
+```
+
+### ConsumeEntry — Multi-Item Ingredient Slots
+
+Each consume entry defines an ingredient slot with multiple valid items. The first matching item found in the player's inventory is consumed.
+
+```yaml
+consume:
+ - items: [item_id, alternative_id, ...]
+ quantity: 1
+ byproducts: [byproduct_for_item, byproduct_for_alt, ...]
+```
+
+`byproducts` is optional. When present, it matches the `items` list by index — consuming `items[0]` returns `byproducts[0]` to inventory. Use `""` for items with no byproduct:
+
+```yaml
+consume:
+ - items: [bucket_of_water, vial_of_water]
+ quantity: 1
+ byproducts: [empty_bucket, ""] # bucket returns empty, vial is consumed entirely
+ - items: [herb]
+ quantity: 1 # no byproducts — herb is consumed entirely
+```
+
+### Skill-Based Production
+
+```yaml
+# data/items/consumables/stim_potion.yaml
+craft:
+ type: pharmacy
+ skill: pharmacy
+ level: 3
+ xp: 25
+ wait: 4
+ consume:
+ - items: [guam_potion_unf]
+ quantity: 1
+ - items: [eye_of_newt]
+ quantity: 1
+ message: "You mix a %n." # %n expands to "stim potion" colored
+```
+
+### Station-Based Production
+
+```yaml
+# data/items/materials/bronze_bar.yaml
+craft:
+ type: smelting
+ skill: smithing
+ level: 1
+ xp: 6
+ wait: 4
+ station: [furnace]
+ consume:
+ - items: [copper_ore]
+ quantity: 1
+ - items: [tin_ore]
+ quantity: 1
+ message: "You smelt a %n." # default is "You remove a white hot %n!"
+ # overridden here for simpler flavor
+```
+
+```yaml
+# data/items/equipment/bronze_dagger.yaml
+craft:
+ type: smithing
+ skill: smithing
+ level: 1
+ xp: 12
+ wait: 4
+ station: [anvil]
+ consume:
+ - items: [bronze_bar]
+ quantity: 1
+ # message omitted — uses default "You smith a %n."
+```
+
+For stackable outputs, use `output_qty`:
+
+```yaml
+# data/items/ammo/bronze_nails.yaml
+craft:
+ type: smithing
+ skill: smithing
+ level: 4
+ xp: 12
+ wait: 4
+ station: [anvil]
+ consume:
+ - items: [bronze_bar]
+ quantity: 1
+ output_qty: 15
+ # message omitted — uses default "You smith a %n."
+```
+
+### Tool-Based Production
+
+```yaml
+# data/items/ammo/arrow_shafts.yaml
+craft:
+ type: fletching
+ level: 1
+ xp: 5
+ wait: 3
+ tool: knife
+ consume:
+ - items: [logs, oak_logs, willow_logs]
+ quantity: 1
+ output_qty: 15
+```
+
+The `tool` field requires the player to have an item with that `tool_type` equipped or in inventory. The tool is NOT consumed.
+
+### Skill-Less Combinations (Item-on-Item)
+
+Omit `type` (or leave it empty) for combinations with no skill check, no XP, and 100% success:
+
+```yaml
+# data/items/consumables/bucket_of_water.yaml
+craft:
+ wait: 0
+ consume:
+ - items: [vial_of_water, jug_of_water]
+ quantity: 1
+ byproducts: [empty_vial, empty_jug]
+ - items: [empty_bucket]
+ quantity: 1
+ message: "You pour the %i1 into the %i2." # %i1 = water source, %i2 = bucket
+```
+
+### Multi-Piece Assembly (3+ items → 1)
+
+```yaml
+# data/items/quest/ancient_map.yaml
+craft:
+ wait: 0
+ consume:
+ - items: [torn_page_1]
+ quantity: 1
+ - items: [torn_page_2]
+ quantity: 1
+ - items: [torn_page_3]
+ quantity: 1
+ steps:
+ - tick: 1
+ message: "You try to puzzle how the pieces fit together."
+ - tick: 3
+ message: "Aha, this edge lines up here!"
+ message: "You assemble the %n."
+```
+
+### Clean Recipes
+
+Clean herb recipes use `type: clean` and are handled as background actions (one herb per cycle, directly mutating inventory):
+
+```yaml
+# data/items/materials/guam.yaml (clean guam)
+craft:
+ type: clean
+ skill: pharmacy
+ level: 3
+ xp: 3
+ wait: 2
+ consume:
+ - items: [grimy_guam]
+ quantity: 1
+ # message omitted — uses default "You clean the %i1."
+ # %i1 expands to the grimy herb name with its color
+```
+
+### Food/Healing Items
+
+Items with `heal_value` and `eat_message` can be consumed via the `eat` command.
+
+```yaml
+id: bread
+name: bread
+color: "222"
+description: "A fresh loaf of bread, still warm from the oven."
+value: 5
+heal_value: 5
+eat_message: "You eat the bread. Warm and satisfying."
+```
+
+### Architecture
+
+The `CraftIndex` (`internal/game/craft_index.go`) is built once at startup from all item YAMLs that have a `craft:` block. It provides `O(1)` lookups by type, input item, and station. All crafting commands query the CraftIndex — no runtime file scanning.
+
+**Indexes:**
+- `byType["pharmacy"]` → all pharmacy-craftable items (used by `mix`)
+- `byInput["guam"]` → all items that consume guam (used by `use` to find combinations)
+- `byStation["anvil"]` → all items craftable at an anvil (used by `use bar on anvil`)
+- `FindByTwoInputs(a, b)` → items consuming both inputs in different consume entries
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
new file mode 100644
index 0000000..735a6b4
--- /dev/null
+++ b/building_guide/rooms.md
@@ -0,0 +1,209 @@
+## Rooms
+
+Minimal room:
+```yaml
+id: 1
+name: "Town Square"
+description: "Cobblestone paths lead in all directions. A fountain gurgles peacefully."
+exits:
+ north: 2
+ west: 7
+ east: 3
+```
+
+### Inline Color Tags
+
+Room descriptions support inline color tags using `{spec}text{/}` syntax. Untagged text uses the `room_desc` color.
+
+```yaml
+description: "On the table lies a {182 bold}mysterious vase{/} with a rose in it."
+```
+
+Tag spec format: `{<0-255> [bold] [dim] [underline]}text{/}`
+
+Gradients: `{g:196,82}gradient text{/}`. Multi-stop: `{g:45,39,59}three stops{/}`.
+
+### Exits — simple vs conditional
+
+Simple exit — always passable:
+```yaml
+exits:
+ north: 2
+```
+
+Conditional exit — blocked until a world flag is set:
+```yaml
+exits:
+ north:
+ room: 11
+ condition:
+ flag: gate_open
+ blocked_message: "A heavy iron gate blocks the way north."
+```
+
+Conditional exit — blocked unless the PLAYER has a flag (key, permission, quest state):
+```yaml
+exits:
+ east:
+ room: 12
+ condition:
+ player_flag: has_vault_key
+ blocked_message: "The vault door is locked. You need a key."
+```
+
+Conditional exit with compound condition — requires both a world flag AND a player flag:
+```yaml
+exits:
+ north:
+ room: 20
+ condition:
+ all_of:
+ - flag: bridge_repaired
+ - player_flag: paid_toll
+ blocked_message: "The bridge is out, and the toll collector blocks the path."
+```
+
+Exit that sets flags when used — `set_flags` / `set_player_flags` are applied
+only when the player actually moves through the exit (not when it's blocked):
+```yaml
+exits:
+ north:
+ room: 21
+ condition:
+ player_flag: lined_up
+ set_player_flags:
+ boarded_shuttle: true # marks "left this area" on the way out
+```
+
+### Item Spawns — ground items that respawn
+
+```yaml
+item_spawns:
+ - id: bronze_pickaxe
+ quantity: 1
+ respawn_ticks: 30 # reappears 30 ticks (18 seconds) after being picked up
+ - id: copper_ore
+ quantity: 3
+ respawn_ticks: 50
+```
+
+### Mobs — NPCs placed in the room
+
+Simple string (no wandering):
+```yaml
+mobs:
+ - "newbie_trainer"
+ - "man"
+```
+
+With wander config per-instance:
+```yaml
+mobs:
+ - id: man
+ wander_interval: 10 # attempts to wander every 10 ticks
+ - id: man
+ wander_interval: 15
+ wander_rooms: [1, 4, 5] # optional — only exit to these rooms
+```
+
+Mob wander config lives in the room YAML, not in the mob definition. This keeps mobs generic
+so the same `man` can wander differently depending on where it's placed. Mobs wander through
+legal (unconditioned) room exits. If no legal exits exist, the mob stays still. Mobs with
+no `wander_interval` never wander.
+
+### Objects — interactive fixtures
+
+```yaml
+objects:
+ - id: copper_rock # simple placement
+ - id: copper_rock # second instance
+ - id: fishing_spot
+ wander_rooms: [7, 8, 9] # teleports between these rooms
+ wander_interval: 12 # every 12 ticks
+ - id: iron_gate # hidden object (see below)
+```
+
+### On-enter scripts — messages and timed cutscenes when a player arrives
+
+Each `on_enter` step shows a `message`, optionally gated by a `condition`. Steps
+whose condition fails are skipped.
+
+```yaml
+on_enter:
+ - message: "The guard barks: \"State your business!\""
+ condition:
+ player_flag: talked_to_guard
+ not: true # only the first visit
+
+ - message: "The guard nods. \"Back again?\""
+ condition:
+ player_flag: talked_to_guard # subsequent visits
+```
+
+**Timed cutscenes.** A step may also carry a `delay` (ticks to wait before it
+fires) and/or set flags (`set_flags` / `set_player_flags`). If any surviving step
+has a delay or sets a flag, the whole sequence runs as a scheduled cutscene;
+plain message-only scripts still print instantly.
+
+```yaml
+on_enter:
+ - condition: { player_flag: boarded, not: true }
+ delay: 5
+ message: "Some of the crowd look you up and down."
+ - condition: { player_flag: boarded, not: true }
+ delay: 5
+ message: "The pilot calls out: \"Tickets, please! Nice and orderly!\""
+ set_player_flags:
+ lined_up: true # opens an exit, flips a description, etc.
+ - condition: { player_flag: boarded, not: true }
+ message: "The crowd forms a single-file line."
+```
+
+Notes:
+- `delay` counts ticks before the step fires; `delay: 0` (or omitted) fires on the next tick.
+- Conditions are evaluated **once** on entry, so a flag a step sets won't cancel a later step in the same sequence.
+- Gate a cutscene on a flag the sequence itself sets (above, `lined_up`) so it doesn't replay on a return visit.
+- If a player disconnects mid-cutscene it resumes on reconnect, so they can't get stuck behind an exit the cutscene was meant to open. Plain `on_enter` messages never replay on login.
+
+### Conditional room descriptions
+
+A room can show a different description depending on the looking player's flags.
+`descriptions` are checked top-to-bottom; the first whose condition passes wins,
+otherwise the base `description` is used.
+
+```yaml
+description: "A large, empty concrete pad in the middle of the ocean."
+descriptions:
+ - condition:
+ player_flag: boarded
+ not: true
+ text: "A concrete pad swarming with a couple dozen anxious passengers."
+```
+
+### Hazardous rooms — environmental danger
+
+A room can reference a shared hazard by ID. The hazard rolls an attack against
+everyone in the room every few ticks using the combat formulas (see `hazards.md`).
+
+```yaml
+id: 99999181
+name: "Exposed Solar Array"
+hazard: solar_radiation # ID of a data/hazards/<id>.yaml definition
+exits:
+ south: 99999180
+objects:
+ - id: rock_outcrop # a safespot object shields players from the hazard
+mobs:
+ - solar_panel_frame # a task worksite (see mobs.md > Task Mobs)
+```
+
+- Players are warned with a `[Y/n]` prompt before walking from a **safe** room into a
+ **hazardous** one (unless they disable the `danger_warning` option). Moving between
+ two hazardous rooms does not re-prompt.
+- A safespot object (an overhang, alcove, rock outcrop, …) shields a hidden player from
+ **all** hazard damage — but performing a melee attack/work step forces you out of cover.
+- Hazards stack with mobs: an aggressive mob in a hazardous room hits you while the room
+ hazard also rolls against you.
+
+---
+
diff --git a/building_guide/search.md b/building_guide/search.md
new file mode 100644
index 0000000..e6e2a14
--- /dev/null
+++ b/building_guide/search.md
@@ -0,0 +1,15 @@
+## The `search` Command
+
+Used to open searchable items in your inventory (bird's nests, etc.):
+```
+Usage: search <item>
+
+Example: search nest
+ search birds nest
+```
+
+Searches your inventory for the named item. If found, removes it and rolls on a drop table.
+Currently only bird's nests are searchable.
+
+---
+
diff --git a/building_guide/state.md b/building_guide/state.md
new file mode 100644
index 0000000..2eefcc4
--- /dev/null
+++ b/building_guide/state.md
@@ -0,0 +1,8 @@
+## State: World vs Player
+
+**World flags** (`set_flags`, checked with `flag`) are shared by every player on the server. A door opened by one player is open for everyone. A lever pulled once changes the world for all.
+
+**Player flags** (`set_player_flags`, checked with `player_flag`) are per-character. Quest progress, "has read the sign," "paid the toll" — these are different for each player. Saved to the character YAML and persist across logins.
+
+---
+
diff --git a/building_guide/tips.md b/building_guide/tips.md
new file mode 100644
index 0000000..307ea1e
--- /dev/null
+++ b/building_guide/tips.md
@@ -0,0 +1,21 @@
+## Tips
+
+1. **Use player flags for quest progress, world flags for environmental state.** If a bridge is repaired, that's world state. If a player has read a sign, that's player state.
+
+2. **Conditions on enter scripts** make rooms feel alive. A guard who only barks the first time, a room that changes after a quest completes.
+
+3. **Hidden objects** keep room descriptions clean. Mention them in the room's description text instead of auto-listing them.
+
+4. **Drop tables** are shareable. The `gem_table` is used by copper rocks AND mob loot. Define once, reference everywhere.
+
+5. **Objects are for fixtures, mobs are for living things.** If it has HP and can die, it's a mob. If it's a rock, lever, door, or crafting station, it's an object. Both can have behaviors.
+
+6. **The `talk` field on mobs** lets you talk to them directly — `talk guard` finds the guard mob, no duplicate object entry needed.
+
+7. **Exits accept both `int` and `map` formats.** `north: 2` is shorthand for `north: {room: 2}`. Add `condition` and `blocked_message` only when needed. Mob room entries accept both `"man"` and `{id: man, ...}`.
+
+8. **Live editing works.** Room, item, mob, object, and behavior YAML files are read from disk on each access. Change a room description or dialog and it takes effect immediately — no restart needed.
+
+9. **Shared depletion vs per-drop depletion.** Use `deplete_timer` for trees — the timer counts down while being chopped and regens when left alone. Use `depletes: true` on individual drops for rocks — they deplete on first successful gather.
+
+10. **Mob wander config goes in the room YAML**, not the mob definition. This lets the same `man` wander differently in different rooms.
diff --git a/building_guide/wandering_mobs.md b/building_guide/wandering_mobs.md
new file mode 100644
index 0000000..8d49ed9
--- /dev/null
+++ b/building_guide/wandering_mobs.md
@@ -0,0 +1,20 @@
+## Wandering Mobs
+
+Mob wandering is configured per-instance in the room YAML, not on the mob definition:
+```yaml
+# In a room:
+mobs:
+ - id: man
+ wander_interval: 10 # attempt to wander every 10 ticks
+ - id: man
+ wander_interval: 15
+ wander_rooms: [5, 6, 7] # optional — restrict which rooms via exits
+```
+
+Mobs wander through legal (unconditioned) room exits. When the wander interval expires,
+the mob picks a random exit with no conditions and moves through it. If `wander_rooms`
+is set, only exits leading to those room IDs are legal. Mobs without `wander_interval`
+never wander. Mobs stop wandering while in combat. Dead mobs respawn at their home room.
+
+---
+
diff --git a/building_guide/wandering_objects.md b/building_guide/wandering_objects.md
new file mode 100644
index 0000000..f1f91c5
--- /dev/null
+++ b/building_guide/wandering_objects.md
@@ -0,0 +1,39 @@
+## Wandering Objects
+
+Fishing spots that move between rooms:
+```yaml
+# data/objects/fishing_spot.yaml
+id: fishing_spot
+name: fishing spot
+gather:
+ skill: fishing
+ level: 1
+ xp: 10
+ base_wait: 4
+ tools:
+ - fishing_rod
+ bait: fishing_bait
+ success:
+ base: 0.30
+ per_level: 0.01
+ cap: 0.90
+ gather_message: "You cast your line into the water..."
+ drops:
+ - item_id: raw_trout
+ weight: 100
+ message: "You catch a raw trout!"
+```
+
+```yaml
+# In a room:
+objects:
+ - id: fishing_spot
+ wander_rooms: [7, 8, 9]
+ wander_interval: 12
+```
+
+Objects teleport between rooms in their `wander_rooms` list. Players gathering from a
+wandering object are silently interrupted when it moves.
+
+---
+