Skip to content

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 north = Throne Room --door --locked --key <iron-key-uuid>

@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.

@dig only sets door metadata on the near side. It creates the return exit route automatically (e.g. south back 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, @goto the 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 @set path resolves against attributes/components of the target entity; a room entity has no exits attribute (exit routes live in the room's data dict and door state lives in ExitMetadataComponent), so that path is not supported. Use @dig flags for new exits, or set ExitMetadataComponent programmatically (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. The has_item lock matches any item whose keywords include iron — a red herring "iron dagger" would satisfy it. It does not check the door's key_id. Use the built-in unlock command (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

  1. ExitMetadataComponent stores per-direction exit state: door, locked, hidden, key_id
  2. The movement command checks locked/door_open before allowing passage; a locked or closed door blocks it
  3. The built-in unlock <direction> command matches the door's key_id against the exact key entity in the player's InventoryComponent — not a keyword
  4. _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 the ExitMetadataComponent.exits dict. 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_field does not call notify_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 the ExitMetadataComponent and call component.notify_mutation() (or reassign component.exits = component.exits) — or reapply the desired state on load.

Variations

  • No key required: Dig with --locked but no --key; unlock then succeeds for anyone (a simple latch)
  • One-use key: After a successful unlock, remove the key from inventory with inventory.remove_item(key.id, weight)
  • Lockpicking: Add your own pick command whose lock uses a skill check, e.g. locks="has_skill(lockpick, 5)", that flips locked off
  • Timed relock: Subscribe to a timer event to call lock <direction> state again after N seconds

See Also