## Rooms **The filename is the ID.** A room's numeric filename is its ID (`1.yaml` → room `1`); the loader derives it from the filename. Do not put an `id:` field in the file — it is ignored. Exits and other rooms reference a room by that number. (A nested `- id:` under `objects:` or `mobs:` is a *reference* to an object/mob by its filename, and is still required.) Minimal room: ```yaml name: "Town Square" description: "Cobblestone paths lead in all directions. A fountain gurgles peacefully." exits: north: room: 2 west: room: 7 east: room: 3 ``` Every exit is a mapping with a `room:` target. The scalar shorthand (`north: 2`) is **not** accepted by the loader: it fails strict YAML decoding and the room is reported as a load ERROR. ### Map color A room may set a `color` that tints its node on the map and the links connecting it: ```yaml color: "5E" ``` A player's per-room `symbol` (set via the `symbol` command) overrides the room color. ### Inline Color Tags Room descriptions support inline color tags using `{spec}text{/}` syntax: ```yaml description: "On the table lies a {B6 bold}mysterious vase{/} with a rose in it." ``` Format: `{<00–FF> [bold] [dim] [underline]}text{/}` Gradients: `{g:C4,52}gradient text{/}`. Multi-stop: `{g:2D,27,3B}three stops{/}`. Named colors can be used as tags too, and they track the live value of a color category (including account overrides): `{command}look sign{/}` paints the text with the current "command" color. Untagged text uses the `room_desc` theme color. --- ### Exits #### Simple exits Always passable, any direction: ```yaml exits: north: room: 2 ne: room: 3 southeast: room: 4 ``` #### Conditional exits Blocked until a condition passes: ```yaml exits: north: room: 11 condition: global_flag: gate_open blocked_message: "A heavy iron gate blocks the way north." ``` The `condition:` field uses the same condition vocabulary as everywhere else — see the [Conditions Reference](#conditions-reference) below. #### Player-flag exits Blocked unless the PLAYER has a flag: ```yaml exits: east: room: 12 condition: player_flag: has_vault_key blocked_message: "The vault door is locked. You need a key." ``` #### Inventory-check exit Blocked unless the player has an item: ```yaml exits: east: room: 6 condition: has_item: rusty_key blocked_message: "The iron door is locked. You need a key." ``` #### Compound conditions ```yaml exits: north: room: 20 condition: all_of: - global_flag: bridge_repaired - player_flag: paid_toll blocked_message: "The bridge is out, and the toll collector blocks the path." ``` #### Exit on_traverse — actions when walked through An exit can carry an `on_traverse` list of Triggers. The first whose filters pass fires when the player moves through the exit (not when it's blocked). Supports the full Trigger shape — `condition`, `item_id`, and a `steps` list. See `triggers.md` for the reference. ```yaml exits: north: room: 21 condition: player_flag: lined_up blocked_message: "You're not in line yet." on_traverse: - steps: - set_player_flags: boarded_shuttle: true ``` #### Hidden exits Hidden exits are invisible in `look` and `verbs` output until the player discovers them by traversing them once. Even hidden, the exit is traversable — just not listed. Once a player moves through a hidden exit, it becomes "discovered" for that character and appears with a `(HIDDEN)` tag. On the map, hidden exits (and rooms only reachable via them) do not appear until the character discovers them. Gods in `god` mode see all hidden exits. ```yaml exits: down: room: 1002 hidden: true ``` #### Always-blocked exits Always-blocked exits are blocked to players but always visible. They show `(blocked)` in `look`/`verbs`/`exits`, refuse movement, and render on the map as blocked `X` connectors. Gods in `god` mode can traverse them. Used for agility course room-to-room links so the map can lay the course out visually. ```yaml exits: north: room: 99999221 always_blocked: true ``` `always_blocked` is absolute — a `condition:` on the same exit is not evaluated. Don't combine them. Hidden and always_blocked can be combined: the exit is invisible until discovered, then shows as blocked. --- ### Map grid & one-way exits All horizontal exits (n/s/e/w/ne/nw/se/sw) must form a consistent 3D grid. Startup validation enforces this from `startup_validation.root_rooms` in config.yaml — it reports **overlap** (two rooms on one cell) or **twist** (one room on two cells) as errors. Exits don't have to be reciprocal. A one-way link renders on the map as a directional arrow instead of a two-way bar. --- ### Item Spawns Ground items that respawn after being picked up: ```yaml item_spawns: - id: bronze_pickaxe quantity: 1 respawn_ticks: 30 # reappears 30 ticks (18s) after pickup - id: copper_ore quantity: 3 respawn_ticks: 50 ``` --- ### Mobs — NPCs placed in the room Simple placement (no wandering): ```yaml mobs: - id: newbie_trainer - id: 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 ``` Wander config lives in the room YAML, not the mob definition. The same `man` can wander differently depending on where it's placed. Mobs wander through un-conditioned room exits. If no legal exits exist, the mob stays still. Mobs with no `wander_interval` never wander. Mobs stop wandering while in combat. Dead mobs respawn at their home room. --- ### Casino Tables Rooms host multiplayer tables under `casino_tables` and single-player slot machines under `casino_machines`. Players join a table with `play `, and a machine by its `variant` (or by its bare game name when it is the room's only machine of that game). Because a table is addressed by its game name, one room may host at most one table per game; video poker cabinets are addressed by their `id`. Multiplayer table fields (`casino_tables`): | Field | Type | Default | Notes | |---|---|---|---| | `id` | string | — | Required. Nonempty, no surrounding whitespace, unique among the room's tables and machines. | | `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, or `video_poker`. | | `max_players` | int | 8 | 0–64. Video poker is forced to 1; Pai Gow caps at 6. | | `betting_ticks` | int | 20 | 0–3600. | | `min_bet` | int | 1 | Must not exceed `max_bet`. | | `max_bet` | int | 1000000 | Must be at least `min_bet`. The sentinel `9223372036854775807` means no cap. | Game rules are nested in a block named after the game. `id` and `game` are the only required keys; omitted numeric fields keep their defaults. #### Blackjack ```yaml casino_tables: - id: blackjack game: blackjack max_players: 6 min_bet: 1 max_bet: 1000 betting_ticks: 20 blackjack: decks: 8 shuffle_at: 0.25 blackjack_payout: 1.5 dealer_hit_soft_17: false max_split_hands: 4 resplit_aces: false double_after_split: true double_split_blackjack: true insurance_payout: 2 surrender: false action_ticks: 20 split_unlike_ten_values: true ``` | Field | Type | Default | Notes | |---|---|---|---| | `decks` | int | 8 | 0–64. | | `shuffle_at` | float | 0.25 | Shoe fraction; must be greater than 0 and less than 1. | | `blackjack_payout` | float | 1.5 | Profit multiplier; at most 5. | | `dealer_hit_soft_17` | bool | false | | | `max_split_hands` | int | 4 | `1` disables splitting, `2` allows one split, and larger values allow re-splitting up to the cap. | | `resplit_aces` | bool | false | | | `double_after_split` | bool | true | | | `double_split_blackjack` | bool | true | Pays a split and doubled 3+ card 21 as a blackjack. | | `insurance_payout` | float | 2 | Profit multiplier; at most 5. | | `surrender` | bool | false | Requires `min_bet` of at least 2. | | `action_ticks` | int | 20 | 0–3600. | | `split_unlike_ten_values` | bool | true | Allows any two 10-valued cards to split. | #### Baccarat ```yaml casino_tables: - id: baccarat game: baccarat max_players: 6 min_bet: 1 max_bet: 1000 betting_ticks: 20 baccarat: variant: commission decks: 8 shuffle_at: 0.25 tie_payout: 8 banker_payout: 0.95 pair_bets: false dragon_7: false panda_8: false super_6_bet: false ``` | Field | Type | Default | Notes | |---|---|---|---| | `variant` | string | `commission` | `commission`, `super_6`, or `ez_baccarat`. | | `decks` | int | 8 | 0–64. | | `shuffle_at` | float | 0.25 | Must be greater than 0 and less than 1. | | `tie_payout` | float | 8 | Profit multiplier; at most 10. | | `banker_payout` | float | 0.95 | Commission tables only; at most 0.99. The `super_6` and `ez_baccarat` variants require 1 (or omit it). | | `pair_bets` | bool | false | Enables the player and banker pair wagers. | | `dragon_7` | bool | false | Enables the Dragon 7 wager. | | `panda_8` | bool | false | Enables the Panda 8 wager. | | `super_6_bet` | bool | false | Enables the optional Super 6 wager; separate from the `super_6` variant. | #### Roulette ```yaml casino_tables: - id: roulette game: roulette max_players: 8 min_bet: 1 max_bet: 10000 betting_ticks: 15 roulette: double_zero: false racetrack: false spin_ticks: 4 ``` | Field | Type | Default | Notes | |---|---|---|---| | `double_zero` | bool | false | false selects the European 37-pocket wheel; true selects the American 38-pocket wheel. | | `racetrack` | bool | false | Adds the French call bets; single-zero tables only. | | `spin_ticks` | int | 3 | 0–3600. | #### Craps ```yaml casino_tables: - id: craps game: craps max_players: 12 min_bet: 1 max_bet: 10000 craps: variant: bank odds: 0 field_12: 2 ``` | Field | Type | Default | Notes | |---|---|---|---| | `variant` | string | `bank` | `bank`, `simplified`, `high_point`, `crapless`, `new_york`, `die_rich`, or `diceless`. Each is a separate house edition. | | `odds` | int | 0 | `0` selects 3-4-5x odds; `1`–`1000000` selects a uniform multiple. Editions without free odds require `0`. | | `field_12` | int | 2 | `2` or `3`: the field's payout on a 12. | #### Pai Gow Poker Pai Gow tables are configured under `pai_gow`; see [Pai Gow Poker tables](pai_gow.md) for every option. #### Video poker Video poker cabinets are single-seat `casino_tables` entries; see [Video poker cabinets](video_poker.md) for every option. #### Slot machines Slots are single-player machines and must be declared under `casino_machines`, not `casino_tables`: ```yaml casino_machines: - id: huff_puff_01 game: slots variant: huff_and_even_more_puff min_bet: 1 max_bet: 1000 spin_ticks: 1 payout: 0.97 ``` | Field | Type | Default | Notes | |---|---|---|---| | `id` | string | — | Required; same rules as table IDs. | | `game` | string | — | Required; only `slots`. | | `variant` | string | `huff_and_even_more_puff` | Registered in Go (`slotVariants` in `internal/casino/registry.go`); an unregistered name is rejected. | | `min_bet` | int | 1 | | | `max_bet` | int | 1000000 | Must be at least `min_bet`. | | `spin_ticks` | int | 2 | 0–3600. | | `payout` | float | 0.97 | Target return-to-player fraction; greater than 0 and at most 1. | #### Casino configuration validation Room create/update requests validate casino definitions before writing; startup validation reports invalid definitions, and game lookup refuses invalid room casino configurations. Unknown fields are rejected. - IDs share one namespace across the room's tables and machines. - One table per game per room. Machines address by `variant`, so several variants may share a room; two variant-less machines of one game, or two machines sharing a variant, are rejected as unreachable. - Omitted or zero numeric fields keep their defaults; negative bets, timers, seat counts, split counts, decks, and payouts are rejected. - Payouts and shuffle fractions must be finite (no NaN or infinity). - Editing a room hot-reconfigures an **idle** table or machine on the next lookup; an occupied game keeps its live rules until it becomes idle, so a committed wager is never discarded. ### Objects — interactive fixtures Simple placement: ```yaml objects: - id: copper_rock - id: copper_rock # second instance ``` With wandering config: ```yaml objects: - id: fishing_spot wander_rooms: [7, 8, 9] # teleports between these rooms wander_interval: 12 # every 12 ticks ``` Wandering objects teleport between rooms in their `wander_rooms` list. Players gathering from a wandering object are silently interrupted when it moves. Local objects (defined directly in the room, no file needed): ```yaml objects: - id: workbench # reference: resolves to data/objects/workbench.yaml - name: window # local: no file, identity comes from name hidden: true description: "A thick trim with rounded bolt heads frames the tiny window." - name: sign aliases: [safety card, frame] inroom_description: "A large framed sign is mounted near the cockpit door." description: "Safety instructions are printed in bold lettering." on_look: - steps: - set_player_flags: 1001_look_sign: true ``` See `objects.md` for the full object reference — local objects, file objects, behaviors, interactions, safespots, stealing, and hidden objects. --- ### On-enter scripts `on_enter` is a `[]Trigger` list. The first entry whose `condition` (and `item_id`, if present) passes fires, and its `steps` run as a scripted sequence (see `triggers.md` for the full Trigger/Step reference). The classic welcome sequence in room 1001 is a single entry whose steps are each gated by their own `condition`: ```yaml on_enter: - steps: - condition: not: true player_flag: 1001_welcome messages: - "{0B bold}Welcome to The House of Icarus{/}" wait: 5 - condition: not: true player_flag: 1001_welcome messages: - "{0B}Type{/} {command}look sign{/} {0B}or{/} {command}talk attendant{/} {0B}to get started{/}" set_player_flags: 1001_welcome: true wait: 7 - condition: not: true player_flag: 1001_welcome messages: - "{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide.{/}" wait: 7 ``` Room 1002's on_enter is the same shape — condition per step, plain-string `messages`, and a `wait` to pace the lines: ```yaml on_enter: - steps: - condition: not: true player_flag: 1002_lined_up messages: - A young boy the crowd openly look you up and down a little bemused, as if you're heading the wrong direction. wait: 7 - condition: not: true player_flag: 1002_lined_up messages: - The pilot pops out of the hatch of the craft and calls out "Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!" wait: 7 - condition: not: true player_flag: 1002_lined_up messages: - The people all shuffle into a line, dragging their luggage out of the path to clear the way for you. set_player_flags: 1002_lined_up: true wait: 7 ``` #### Timed sequences `wait: N` on a step is the number of ticks to pause **before** that step fires (replaces the old `delay`). Messages are plain strings; effects on a step fire together when the wait elapses. A step with no `wait` fires on the next tick. #### Step actions On-enter steps support the full Step vocabulary: `messages`, `broadcast`, `broadcast_global`, `spawn_mob`, `despawn_mob`, `give_item`, `take_item`, `teleport`, `heal`, `credits`, `aps_node`, `set_global_flags`, `set_player_flags`, plus a per-step `condition`. See the [Step vocabulary](triggers.md#step-vocabulary) table. ```yaml on_enter: - condition: player_flag: boss_summoned steps: - wait: 10 broadcast: "The ground trembles..." - wait: 20 broadcast: "A massive guardian emerges from the shadows!" spawn_mob: id: altar_guardian owner_only: true despawn_on_leave: true ``` #### First-match-wins and migration Entries in `on_enter` are walked top-to-bottom; the first whose `condition` passes fires and its steps run. The old on_enter ran **every** matching step, so migration wraps the old step list into a single Trigger entry to preserve behavior. For new content where steps are mutually exclusive, use multiple entries with distinct conditions. Notes: - `wait: 0` (or omitted) fires on the next tick. - Gate a sequence on a flag the sequence itself sets so it doesn't replay on return visits. - A `lock: true` Trigger resumes on reconnect; unlocked sequences are not persisted across disconnect. - Setting player flags from on_enter steps fires flag-change triggers watching those flags. --- ### On-exit scripts `on_exit` is the mirror of `on_enter`: a `[]Trigger` list that fires when a player **leaves** the room. It runs **before** `RoomID` is updated to the destination, so broadcasts and `spawn_mob` resolve against the room the player is leaving. ```yaml on_exit: - condition: player_flag: boarded steps: - broadcast: "The shuttle hatch clanks shut behind the departing passenger." - set_player_flags: left_pad: true ``` Use on_exit for parting messages, closing out a spawn when a player departs, or recording that the player has passed through an area. It obeys the same first-match-wins rule and the same Step vocabulary as on_enter. (New event block — did not exist before the Trigger unification.) --- ### Conditional room descriptions A room's `description` can be a plain string or a list of conditional variants. Entries are checked top-to-bottom; the first whose condition passes wins. An entry with no condition always matches — put it last as fallback. ```yaml description: - condition: player_flag: boarded not: true text: "A concrete pad swarming with anxious passengers." - text: "A large, empty concrete pad in the middle of the ocean." ``` --- ### Hazardous rooms A room can reference a shared hazard by ID. The hazard rolls an attack against everyone in the room every few ticks using combat formulas. ```yaml name: "Exposed Solar Array" hazard: solar_radiation # ID of data/hazards/solar_radiation.yaml exits: south: room: 99999180 objects: - id: rock_outcrop # safespot object shields players from the hazard mobs: - id: solar_panel_frame # task worksite (see mobs.md) ``` - Players get a `[Y/n]` confirmation when walking from a safe room into a hazardous one (unless `danger_warning` option is disabled). Moving between two hazardous rooms does not re-prompt. - Safespot objects shield hidden players from ALL hazard damage. - Hazards stack with mobs: an aggressive mob in a hazardous room hits you while the hazard also rolls. See `hazards.md` for the full hazard definition reference. --- ### Flag triggers A trigger that reacts to a flag change is a **file-backed trigger**, not a room field. Put it under `data/triggers/player_flag/` (watch a player flag with `on_player_flag:`) or `data/triggers/global_flag/` (watch a global flag with `on_global_flag:`). Each file holds one trigger, the filename stem is its id, and the trigger must set exactly one of those two keys — a file with neither is a startup ERROR because it is loaded but can never fire. See `triggers.md` for the full reference. ```yaml # data/triggers/player_flag/1001_touchdown.yaml on_player_flag: lever_pulled condition: room: 1001 # opt-in: only fires if the player is in this room steps: - wait: 5 messages: ["The cabin shakes as the small craft touches down"] - wait: 5 messages: ["The pistons hiss as the rear staircase opens"] - set_player_flags: 1001_touchdown: true ``` Flag triggers listen **globally** — they fire regardless of where the flag is set. Add `condition: { room: }` to scope one to the room it belongs to; delete that condition to let it fire wherever the player is. Room files support only `on_enter`, `on_exit`, and exit `on_traverse` trigger blocks; there is no room-level `on_flag_change:` field. --- ### Conditions Reference Conditions are used in exit gates, Trigger and step gates, room descriptions, talk option guards, and flag-trigger value matching. #### Simple conditions A bare `global_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:` to match a specific value. `not: true` inverts any check. ```yaml # Global flag is set condition: global_flag: gate_open # Global flag is NOT set condition: global_flag: gate_open not: true # Player flag is set condition: player_flag: finished_tutorial # Player flag is NOT set (e.g. only on first visit) condition: player_flag: finished_tutorial not: true # Match a specific (non-boolean) value condition: player_flag: quest_stage value: 3 # Player has an item condition: has_item: bronze_key # Player does NOT have an item condition: has_item: bronze_key not: true # Player has enough credits condition: min_credits: 50 # Player is currently in a specific room (use for flag-trigger scoping) condition: room: 1001 ``` #### Compound conditions All must pass: ```yaml condition: all_of: - global_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 ``` --- ### Door Pattern Examples #### Global door: Button in another room (shared state) 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 name: stone button hidden: true on_use: - condition: global_flag: secret_door_open not: true steps: - messages: ["You press the stone button. You hear grinding stone in the distance."] set_global_flags: secret_door_open: true ``` **Room 7** — contains the door: ```yaml name: "Hidden Passage" exits: south: room: 2 north: room: 8 condition: global_flag: secret_door_open blocked_message: "A heavy stone door blocks the way." objects: - id: stone_door hidden: true ``` #### Player door: Key-locked (per-player state) 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 name: rusty key description: "An old iron key, still functional." value: 0 stackable: false ``` **Room 5** — locked door: ```yaml name: "Locked Storage" exits: west: room: 2 east: room: 6 condition: has_item: rusty_key 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 name: "Storage Closet" exits: west: 5 # exit back — no condition item_spawns: - id: uncut_ruby quantity: 1 respawn_ticks: 500 ``` The key difference: the button door uses `global_flag` (shared — one player presses, everyone benefits); the key door uses `has_item` (per-player inventory — each player needs their own key).