Skip to content

Hidden Room

Problem

You want a secret exit that's invisible until the player uses a search command to discover it.

Solution

Setting Up the Hidden Exit

MAID's ExitInfo has a built-in hidden flag. Hidden exits don't appear in room descriptions or standard movement until revealed.

Builder commands:

@dig east = Secret Treasury
@set here/exits/east/hidden = true
@set here/exits/east/door = true
@set here/exits/east/door_name = concealed passage

Programmatic setup:

from uuid import UUID
from maid_engine.core.world import World
from maid_stdlib.components import (
    DescriptionComponent,
    ExitMetadataComponent,
    ExitInfo,
)


def create_hidden_passage(
    world: World, room_id: UUID, secret_room_id: UUID
) -> None:
    """Add a hidden exit to an existing room."""
    room = world.get_entity(room_id)
    if not room:
        return

    # Wire the exit route in room data so movement works once revealed
    room_data = world.get_room(room_id)
    if room_data is not None:
        if isinstance(room_data, dict):
            room_data.setdefault("exits", {})["east"] = secret_room_id
        elif hasattr(room_data, "exits"):
            room_data.exits["east"] = secret_room_id

    # Mark the exit as hidden so it's invisible until discovered
    exit_meta = room.try_get(ExitMetadataComponent)
    if exit_meta:
        exit_meta.exits["east"] = ExitInfo(
            hidden=True,
            door=True,
            door_name="concealed passage",
        )
        exit_meta.notify_mutation()
    else:
        room.add(ExitMetadataComponent(
            exits={
                "east": ExitInfo(
                    hidden=True,
                    door=True,
                    door_name="concealed passage",
                ),
            },
        ))

The Search Command

import random
from uuid import UUID

from maid_engine.commands.decorators import command
from maid_engine.commands import CommandContext
from maid_stdlib.components import ExitMetadataComponent


@command(
    name="search",
    aliases=["examine walls", "look around"],
    category="exploration",
    help_text="Search the room for hidden passages",
)
async def cmd_search(ctx: CommandContext) -> bool:
    """Search the current room for hidden exits."""
    room_id = ctx.world.get_entity_room(ctx.player_id)
    if not room_id:
        return False

    room = ctx.world.get_entity(room_id)
    if not room:
        return False

    exit_meta = room.try_get(ExitMetadataComponent)
    if not exit_meta:
        await ctx.session.send("You search but find nothing unusual.\n")
        return True

    found_something = False
    for direction, exit_info in exit_meta.exits.items():
        if not exit_info.hidden:
            continue

        # Skill check — adjust success chance as desired
        search_chance = 0.6  # 60% base chance
        if random.random() < search_chance:
            exit_info.hidden = False
            exit_meta.notify_mutation()
            name = exit_info.door_name or "passage"
            await ctx.session.send(
                f"You discover a {name} to the {direction}!\n"
            )
            found_something = True

    if not found_something:
        await ctx.session.send(
            "You search carefully but find nothing unusual.\n"
        )

    return True

Skill-Based Discovery

Integrate with a skill system for more depth:

from maid_classic_rpg.components import CharacterInfoComponent


@command(
    name="search",
    category="exploration",
    help_text="Search for hidden passages (Perception check)",
)
async def cmd_search_skilled(ctx: CommandContext) -> bool:
    """Search with skill-based success chance."""
    room_id = ctx.world.get_entity_room(ctx.player_id)
    if not room_id:
        return False

    room = ctx.world.get_entity(room_id)
    if not room:
        return False

    exit_meta = room.try_get(ExitMetadataComponent)
    if not exit_meta:
        await ctx.session.send("You search but find nothing unusual.\n")
        return True

    # Get player's perception/search skill
    player = ctx.world.get_entity(ctx.player_id)
    if not player:
        return False

    info = player.try_get(CharacterInfoComponent)
    player_level = info.level if info else 1

    found_something = False
    for direction, exit_info in exit_meta.exits.items():
        if not exit_info.hidden:
            continue

        # Higher level = better search chance
        difficulty = 10  # Base DC
        roll = random.randint(1, 20) + player_level
        if roll >= difficulty:
            exit_info.hidden = False
            exit_meta.notify_mutation()
            name = exit_info.door_name or "passage"
            await ctx.session.send(
                f"[Perception {roll} vs DC {difficulty}] "
                f"You discover a {name} to the {direction}!\n"
            )
            found_something = True
        else:
            await ctx.session.send(
                f"[Perception {roll} vs DC {difficulty}] "
                f"You feel like something is off, but can't quite tell...\n"
            )

    if not found_something:
        await ctx.session.send("You search carefully but find nothing.\n")

    return True

Re-hiding on Reset

If you want the passage to re-hide itself (e.g., on area reset):

from maid_engine.core.ecs import System
from maid_engine.core.world import World
from maid_stdlib.components import ExitMetadataComponent


class HiddenExitResetSystem(System):
    """Periodically re-hides discovered hidden exits."""

    priority = 250

    def __init__(self, world: World, reset_interval: float = 3600.0) -> None:
        super().__init__(world)
        self.reset_interval = reset_interval
        self._timer: float = reset_interval
        self._originally_hidden: dict[str, list[str]] = {}  # entity_id -> directions

    async def update(self, delta: float) -> None:
        self._timer -= delta
        if self._timer > 0:
            return
        self._timer = self.reset_interval

        for entity in self.entities.with_components(ExitMetadataComponent):
            exit_meta = entity.get(ExitMetadataComponent)
            key = str(entity.id)
            if key in self._originally_hidden:
                for direction in self._originally_hidden[key]:
                    exit_info = exit_meta.exits.get(direction)
                    if exit_info and not exit_info.hidden:
                        exit_info.hidden = True
                exit_meta.notify_mutation()

How It Works

  1. ExitInfo.hidden = True removes the exit from room descriptions and blocks standard movement
  2. The search command iterates over exits, checks for hidden, and reveals with a random/skill check
  3. notify_mutation() marks the entity dirty so persistence picks up the state change
  4. Setting hidden = False immediately allows normal movement through that exit

Variations

  • Detect magic: A separate detect command that only finds magically hidden passages
  • Item-triggered: Require a specific item (torch, magnifying glass) to search effectively
  • One-player reveal: Track per-player discovery so each player must find the exit themselves
  • Trap: The search reveals a trap instead — deal damage before showing the exit
  • Progressive hints: First search says "You notice scratch marks on the wall", second reveals the passage

See Also