## 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 may host one or more multiplayer casino tables. Players join by typing `play `; the table ID is an internal room-local identifier and does not need to be typed. Blackjack and baccarat are the multiplayer table games; slots run as single-player machines (see below), and older rooms that declare slots under `casino_tables` are migrated to machines automatically. `max_players`, `betting_ticks`, `min_bet`, and `max_bet` use defaults when omitted. Game-specific rule configuration is nested alongside the table definition. Blackjack tables support sequential player turns and configurable house rules: ```yaml casino_tables: - id: blackjack game: blackjack max_players: 6 betting_ticks: 20 min_bet: 1 max_bet: 1000 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 ``` Blackjack supports `hit`, `stand`, `double`, `split`, `insurance`, and `surrender`. A player blackjack against a dealer blackjack pushes. When the dealer's up card is an ace, an insurance window opens before anyone plays: each bettor may type `insurance` to buy cover for half their base bet, and any other action declines. Once every bettor has decided (or the window times out), a dealer blackjack settles the round immediately with insurance paying its configured profit multiplier; otherwise play begins. Split hands can be configured independently for re-splitting aces and doubling, and split aces receive one card each with no further actions beyond a configured re-split. Two blackjack knobs are player-favourable and on by default, so set them explicitly when authoring a standard-rules table: - `split_unlike_ten_values: true` lets any two 10-valued cards split (K+Q, 10+J). Casinos vary: some permit any ten-valued pair, others require identical ranks. - `double_split_blackjack: true` pays a 3+ card hand that reaches 21 after a split and double as a blackjack (3:2). Standard play pays 1:1 for any 21 on a split hand, doubled or not. Both raise the table's actual RTP above the nominal blackjack payout. Baccarat's `banker_payout` (0.95 = a 5% commission) rounds the commission UP: a winning banker bet too small to carry a whole credit of commission returns only the stake rather than paying a commission-free 1:1. Payout multipliers are capped (`blackjack_payout`/`insurance_payout` ≤ 5, `tie_payout` ≤ 10, commission-table `banker_payout` ≤ 0.99) so a typo cannot silently create an absurd table. No-commission baccarat variants require `banker_payout: 1` (or omit it); their compensating exceptions are enforced by the engine. ### Blackjack rule cards and test tables `look blackjack`, `look blackjack table`, or `look ` displays the fixture's complete effective rule card (including omitted/default settings, fixed engine rules, limits, and timers). Joining with `play blackjack` also shows it privately. No scenery object or room-description text is required: placing a `casino_tables` entry in any room brings the rule card with it. Currently only one table of each game is allowed per room; use separate rooms for different blackjack tables. The casino has these test profiles: | Room | Table | Decks | Blackjack | Soft 17 | DAS | Maximum hands | Re-split aces | Surrender | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 6110 | Classic | 6 | 3:2 | Stand | Yes | 4 | No | No | | 6112 | Mercy | 6 | 3:2 | Stand | Yes | 4 | Yes | Late | | 6114 | Single Deck | 1 | 6:5 | Hit | No | 2 | No | No | | 6116 | Midnight | 8 | 3:2 | Hit | Yes | 4 | No | No | All four disable the nonstandard split-and-double blackjack bonus. Use `blackjack_payout: 1.5` for 3:2 or `1.2` for 6:5. `max_split_hands: 1` disables splitting; 2 allows one split, and larger values allow re-splitting within the cap (aces additionally require `resplit_aces`). Common casino variations not currently configurable are restricted double-down totals (e.g. 9–11 or 10–11 only), hitting split aces, disabling insurance, and a separate even-money action for a natural against an ace. Doubling is always for the full original stake, not “double for less.” Late surrender is supported; early surrender is not. Side bets are not implemented. Casino-fidelity caveats: fractional credits round down. Shoe coverage may trigger an early shuffle, and exhaustion rebuilds the shoe mid-round rather than guaranteeing a casino-style finite shoe for the whole round. A connected solo player's action timer also has special grace behavior. None of these mechanics were changed by adding rule cards. At a blackjack table, `bet` uses the configured minimum wager; `bet min`, `bet max`, and `bet ` select the wager explicitly. Card art is loaded from `data/cards.yaml` and is shared by every card game (blackjack, baccarat). Each of the 52 rank/suit keys may define Unicode art for sizes 5, 7, and 9. Missing art uses the generated renderer. Players can choose `option card_size 1`, `5`, `7`, or `9`; size 1 uses Unicode playing-card symbols. ASCII mode always uses generated 5x5 cards. `option card_style light` adds a white card background; `dark` is the default style. The same art renders baccarat hands. Baccarat tables play punto banco: every participant bets on the player, the banker, or a tie, then the dealer plays out both hands by fixed drawing rules — there are no mid-round player decisions: ```yaml casino_tables: - id: baccarat game: baccarat max_players: 6 betting_ticks: 20 min_bet: 1 max_bet: 1000 baccarat: variant: commission # commission (default), super_6, ez_baccarat decks: 8 shuffle_at: 0.25 tie_payout: 8 # 8:1 or 9:1 are common banker_payout: 0.95 # omit or use 1 for no-commission variants pair_bets: false # player_pair and banker_pair, 11:1 dragon_7: false # winning three-card Banker 7, 40:1 panda_8: false # winning three-card Player 8, 25:1 super_6_bet: false # winning Banker 6, 12:1 ``` At a baccarat table, `bet on player`, `bet on banker`, and `bet on tie` place wagers (the words and amounts may come in any order, and `min`/`max` work as usual). A bare `bet` repeats the previous amount and target. Winning player bets pay 1:1; on commission tables winning banker bets pay `banker_payout`:1 (0.95 is the classic 5% commission); winning tie bets pay `tie_payout`:1. On a tie, player and banker bets push. When the betting window closes, both hands are dealt by the standard tableau: naturals (8 or 9) stand immediately, the player hand draws on 0-5, and the banker hand follows the usual third-card rules. #### Baccarat variants, rule cards, and test tables - **Commission punto banco:** default 5% commission on Banker winnings; reduced commission can be configured with `banker_payout` (e.g. 0.98). - **Super 6 (`super_6`):** no commission; Banker wins pay 1:1 except a winning Banker 6, with either two or three cards, pays 1:2. - **EZ Baccarat (`ez_baccarat`):** no commission; Banker wins pay 1:1 except a winning **three-card** Banker 7 pushes the Banker wager. Player and Tie wagers still lose on that outcome. A two-card Banker 7 pays normally. All variants use the same automatic punto banco drawing tableau. Standard, mini-, and midi-baccarat mainly differ in seating, limits, dealing pace, and card handling, not this tableau; they do not need separate settlement engines. Deck count, cut threshold, limits, seats, and Tie payout remain table-specific. Optional wagers default off and may be enabled independently on any variant. Use `bet 10 on player_pair`, `banker_pair`, `dragon_7`, `panda_8`, or `super_6` where offered. Pairs mean matching **ranks** of the first two cards (not merely matching baccarat values); they settle independently of which hand wins and can win on a tie. Dragon 7, Panda 8, and Super 6 require an outright hand win, not a tie. Their fixed profit odds are listed in the configuration example. The separate Super 6 optional bet is not the main Banker's half-pay rule. **Current wagering limitation:** one wager per player per round. Optional bets are selectable wagers, not additions to a simultaneous Player/Banker bet. All use the table's bet limits. Fractional winnings round down; for exact 5% commissions use multiples of 20 credits, and for half-pay wins use even stakes. Commission is deducted immediately, not collected later on a ledger. `look baccarat`, `look baccarat table`, and `look ` display the full rule card, as does joining with `play baccarat`. It lists every supported setting, including disabled optional bets, automatic drawing rules, rounding, and operational limits. It belongs to the table and works in any room without a plaque or special room text. One baccarat table is allowed per room. | Room | Table | Variant | Decks | Tie pays | Optional wagers | | --- | --- | --- | --- | --- | --- | | 6127 | Harbor | 5% commission | 8 | 8:1 | Player/Banker pair | | 6128 | Charter | Super 6 | 6 | 9:1 | Pairs, Super 6 | | 6129 | Crown | EZ Baccarat | 8 | 8:1 | Dragon 7, Panda 8 | Not modeled: player squeeze/shoe-passing ceremonies, burn-card rituals, deferred commission, simultaneous wagers, and other casino-specific side-bet paytables (such as Dragon Bonus or progressive jackpots). These are not implied by selecting an implemented variant. Single-player machines use `casino_machines` and are separate from multiplayer 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 ``` #### 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. Raw file editing is not a validated save endpoint. - IDs must be nonempty, have no surrounding whitespace, and be unique across the room's tables and machines. - Supported table games are `blackjack` and `baccarat`; slots are machines only and must be declared under `casino_machines`. A `slots` entry under `casino_tables` is rejected. Machines use `slots` with the optional variant `huff_and_even_more_puff`. A machine variant is registered in Go (`slotVariants` in `internal/casino/registry.go`); naming an unregistered variant is a validation error. - A multiplayer table is addressed by its game name (`play blackjack`), so a room may host only one table per game. - A machine is addressed by its `variant` when one is declared, so a room may host several variants of one game, each reached by its own variant name. A bare game name addresses a machine only when it is the sole machine of that game (or declares no variant). Two variant-less machines of one game, or two machines sharing a variant, are rejected as unreachable duplicates. - Omitted/zero numeric values keep their existing defaults. Negative bets, timers, seat counts, split counts, decks, and payouts are rejected. - An explicit `max_bet` must be at least `min_bet`. An omitted or zero `max_bet` uses the default cap (1,000,000); the explicit sentinel `9223372036854775807` means no cap. - A blackjack table that offers `surrender` must set `min_bet` to at least 2: a surrendered hand returns half the bet, which rounds down to zero below that (the omitted default of 1 is rejected too). - Shoe coverage is best-effort, not a hard floor. The shoe reshuffles at the start of a round when it is smaller than the heuristic round size (eleven cards per hand--an all-ace hand can exceed that), and `draw()` rebuilds an exhausted shoe in place. A small `decks` value therefore never invalidates a table or darkens the room's other casino games. - `decks` is capped at 64 to bound shoe allocation. An explicit `shuffle_at` must be greater than zero and less than one. - Payouts and shuffle fractions must be finite (no NaN/infinity). Slots `payout` is an RTP fraction greater than zero and at most one; zero defaults to 0.97. - Unknown casino fields are rejected by the room create/update API. 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. Use explicit, modest wager limits: validation does not yet make currency arithmetic overflow-safe. ### 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).