Locked Door¶
Problem¶
You want a door that only opens when the player carries a specific key item. The door should block movement until unlocked.
Solution¶
This uses MAID's built-in lock expressions and ExitMetadataComponent — no custom system needed for the basic case.
Setting Up the Locked Exit (Builder Commands)¶
The fastest way is to use in-game builder commands. First create the key item so you have its UUID, then dig a locked door that references it:
@create item Iron Key
@set Iron Key/keywords = ["iron", "key"]
@describe Iron Key = A heavy iron key with ornate teeth. It looks like it fits a large door.
@examine Iron Key # note the UUID printed here
@dig accepts --door, --locked, --hidden, and --key <uuid> (see
maid_stdlib/commands/building/dig.py). --locked and --key both imply
--door. The value passed to --key must be a valid entity UUID — the Iron Key
you just created — not a bare string. @dig stores this door/lock state in an
ExitMetadataComponent on the room, which the movement command consults before
allowing passage.
@digonly sets door metadata on the near side. It creates the return exit route automatically (e.g.southback to the hallway), but it does not copy the door/locked/key metadata onto that reverse exit. If you want the door to be locked from both rooms,@gotothe new room and@dig/set the reverse exit's metadata too, or create both sides programmatically (below).Do not use
@set here/exits/north/locked = true. The@setpath resolves against attributes/components of the target entity; a room entity has noexitsattribute (exit routes live in the room's data dict and door state lives inExitMetadataComponent), so that path is not supported. Use@digflags for new exits, or setExitMetadataComponentprogrammatically (below).
To create the locked door in code (e.g., during on_load):
from uuid import UUID
from maid_engine.core.world import World
from maid_stdlib.components import (
DescriptionComponent,
ExitMetadataComponent,
ExitInfo,
ItemComponent,
PositionComponent,
)
async def create_locked_door_area(world: World) -> None:
"""Create two rooms connected by a locked door."""
# Create the key item first so we can reference its UUID on the door
key = world.create_entity()
key.add(DescriptionComponent(
name="Iron Key",
short_desc="a heavy iron key",
long_desc="A heavy iron key with ornate teeth.",
keywords=["iron", "key"],
))
key.add(ItemComponent(
item_type="key",
weight=0.5,
value=0,
))
key.add_tag("item")
# Create the rooms
hallway = world.create_entity()
hallway.add(DescriptionComponent(
name="Grand Hallway",
long_desc="A long stone hallway stretches before you. "
"An iron-bound door blocks the way north.",
keywords=["hallway"],
))
hallway.add_tag("room")
world.register_room(hallway.id, {"name": "Grand Hallway"})
# Place the key somewhere reachable so a player can actually pick it up and
# unlock the door — otherwise the key is orphaned and the door can never be
# opened. Give it a PositionComponent (so its location is tracked/persisted)
# and add it to the hallway's contents (cmd_get searches entities_in_room).
key.add(PositionComponent(room_id=hallway.id))
world.place_entity_in_room(key.id, hallway.id)
throne_room = world.create_entity()
throne_room.add(DescriptionComponent(
name="Throne Room",
long_desc="A magnificent throne sits atop a raised dais.",
keywords=["throne", "room"],
))
throne_room.add_tag("room")
world.register_room(throne_room.id, {"name": "Throne Room"})
# Store the door/lock state in ExitMetadataComponent (movement consults this).
# key_id references the key entity's UUID and is enforced by the built-in
# `unlock` command: it requires that exact key entity in the player's
# inventory (see maid_stdlib/commands/doors.py::cmd_unlock).
hallway.add(ExitMetadataComponent(
exits={
"north": ExitInfo(
door=True,
locked=True,
key_id=str(key.id),
door_name="iron-bound door",
),
},
))
# Wire the actual exit route in the room's data dict so movement can resolve
# the destination once the door is unlocked.
hallway_data = world.get_room(hallway.id)
if isinstance(hallway_data, dict):
hallway_data.setdefault("exits", {})["north"] = throne_room.id
# Set the SAME door state on the reverse exit. `@dig` (and this code) create
# a return route automatically, but door/lock metadata is per-direction and
# is NOT mirrored for you. Without this block the door would be locked from
# the hallway but wide open from the throne room.
throne_room.add(ExitMetadataComponent(
exits={
"south": ExitInfo(
door=True,
locked=True,
key_id=str(key.id),
door_name="iron-bound door",
),
},
))
throne_data = world.get_room(throne_room.id)
if isinstance(throne_data, dict):
throne_data.setdefault("exits", {})["south"] = hallway.id
Unlocking the Door (Built-in Commands)¶
You do not need a custom command. maid-stdlib ships open, close,
lock, and unlock player commands (maid_stdlib/commands/doors.py). The
built-in unlock <direction> enforces the key exactly: it reads key_id
off the exit, parses it as a UUID, and requires that specific key entity in the
player's InventoryComponent:
# From cmd_unlock — exact-key enforcement, not a keyword match:
key_uuid = UUID(str(key_id_str))
inv = entity.try_get(InventoryComponent)
if inv is None or key_uuid not in inv.items:
await ctx.session.send("You don't have the right key.\n")
return False
So the player experience is:
> north
The door is locked.
> unlock north
You unlock the door to the north.
> open north
You open the door to the north.
> north
Throne Room
Avoid
locks="has_item(iron)"for key doors. Thehas_itemlock matches any item whose keywords includeiron— a red herring "iron dagger" would satisfy it. It does not check the door'skey_id. Use the built-inunlockcommand (exact UUID match) instead of gating a custom command on a keyword.
Because door state is per-direction, remember to unlock/open from whichever side
the player is on. If you want a door that only locks from the hallway side, set
ExitInfo(locked=True, ...) on the hallway's north exit only and leave the
throne room's south exit as a plain door (locked=False).
How It Works¶
- ExitMetadataComponent stores per-direction exit state:
door,locked,hidden,key_id - The movement command checks
locked/door_openbefore allowing passage; a locked or closed door blocks it - The built-in
unlock <direction>command matches the door'skey_idagainst the exact key entity in the player'sInventoryComponent— not a keyword _set_exit_meta_field(...)(used by the door commands) writes the new state directly into the component
⚠ Known limitation — door state is not durably persisted. The built-in door commands change exit state via
setattr(comp.exits[direction], field, value)— a nested mutation of an object inside theExitMetadataComponent.exitsdict. The ECS dirty tracker only auto-marks a component dirty when one of the component's own public fields is reassigned (comp.field = ...); it does not observe mutations of nested objects, and_set_exit_meta_fielddoes not callnotify_mutation(). As a result, unlocking/opening/locking a door at runtime updates in-memory state (movement is affected immediately) but is not saved by the persistence system and is lost on restart. If you need a door's runtime state to survive a reboot, mark the room entity dirty yourself after the command — e.g. fetch theExitMetadataComponentand callcomponent.notify_mutation()(or reassigncomponent.exits = component.exits) — or reapply the desired state on load.
Variations¶
- No key required: Dig with
--lockedbut no--key;unlockthen succeeds for anyone (a simple latch) - One-use key: After a successful
unlock, remove the key from inventory withinventory.remove_item(key.id, weight) - Lockpicking: Add your own
pickcommand whose lock uses a skill check, e.g.locks="has_skill(lockpick, 5)", that flipslockedoff - Timed relock: Subscribe to a timer event to call
lock <direction>state again after N seconds
See Also¶
- Command System Guide — Lock expressions reference
- Building Commands —
@set,@dig,@create - ECS Components — How components work