Skip to content

Level Gating

Problem

You want to restrict access to a dangerous area so that only players at or above a minimum character level can enter.

Solution

MAID's lock expressions make this trivial. The built-in char_level(n) lock function checks the player's character level on a stats component.

Pick the right level() variant

  • char_level(n) — gameplay/skill gates (character's StatsComponent.level). Use this for area locks, spell prerequisites, equipment tiers, etc. Switching characters changes the value.
  • level(name) / account_level(name) — admin/account gates (account's AccessLevel). Use this for admin commands. Follows the account across characters. Accepts symbolic names (player|helper|builder|admin|implementor) or the ordinal int 0-4.

Older docs and examples used level(n) as a character-level check. As of the account-tied level() change it now reads the account; the dedicated character-level function is char_level(n).

Using Lock Expressions on a Command

Gate a movement command or area entrance:

from maid_engine.commands.decorators import command
from maid_engine.commands import CommandContext


@command(
    name="enter_dungeon",
    category="movement",
    help_text="Enter the Abyssal Depths (requires character level 10)",
    locks="char_level(10)",
)
async def cmd_enter_dungeon(ctx: CommandContext) -> bool:
    """Enter the level-gated dungeon."""
    dungeon_entrance_id = ctx.world.get_data("dungeons", "abyssal_depths")
    if not dungeon_entrance_id:
        await ctx.session.send("The dungeon entrance is sealed.\n")
        return False

    ctx.world.move_entity(ctx.player_id, dungeon_entrance_id)
    await ctx.session.send(
        "You descend into the Abyssal Depths. The air grows cold...\n"
    )
    return True

If the player's character level is below 10, the command is automatically blocked and the player receives a permission error.

Reactive Gate (Post-Move Bounce-Back)

RoomEnterEvent fires after the move completes.

World.move_entity() updates the room index and the entity's PositionComponent before it emits RoomLeaveEvent/RoomEnterEvent (core/world.py). Calling event.cancel() inside a RoomEnterEvent handler does not undo the move — cancel() only stops later handlers of the same event from running. To keep an under-leveled player out this way you must explicitly move them back, which briefly places them in the gated room and emits an extra pair of leave/enter events. Prefer a real pre-move gate (see below) when you need to actually prevent entry.

If a reactive bounce-back is acceptable, subscribe to RoomEnterEvent and move the entity back:

from uuid import UUID

from maid_engine.core.ecs import System
from maid_engine.core.events import RoomEnterEvent


def _room_min_level(room: object | None) -> int:
    """Read a room's minimum-level requirement (0 = no gate).

    Builders set this with `@set here/min_level = 10`, which stores `min_level`
    on the room data returned by `world.get_room()`. Read it back the same way
    rather than relying on a separate table that nothing populates.
    """
    if room is None:
        return 0
    if isinstance(room, dict):
        return int(room.get("min_level", 0) or 0)
    return int(getattr(room, "min_level", 0) or 0)


class LevelGateSystem(System):
    """Bounces under-leveled players back out of gated rooms."""

    priority = 5  # Run very early, before other movement processing

    async def startup(self) -> None:
        # Keep the handler id so we can unsubscribe on shutdown. Otherwise a
        # stale handler bound to this (now-dead) system survives a hot-reload or
        # system removal and fires against a torn-down system.
        self._enter_sub = self.events.subscribe(RoomEnterEvent, self._check_level_gate)

    async def shutdown(self) -> None:
        self.events.unsubscribe(self._enter_sub)

    async def update(self, delta: float) -> None:
        return None  # Event-driven; no per-tick work

    async def _check_level_gate(self, event: RoomEnterEvent) -> None:
        """Bounce the entity back if it is under-leveled."""
        min_level = _room_min_level(self.world.get_room(event.room_id))
        if min_level <= 0:
            return  # Not a gated room

        entity = self.world.get_entity(event.entity_id)
        if not entity or not entity.has_tag("player"):
            return  # Only gate players, not NPCs

        # Check character level via CharacterInfoComponent
        # (from maid-classic-rpg or your own level component)
        from maid_classic_rpg.components import CharacterInfoComponent
        info = entity.try_get(CharacterInfoComponent)
        player_level = info.level if info else 0

        # The move has ALREADY happened. cancel() only stops later handlers;
        # to keep the player out we must move them back to where they came from.
        if player_level < min_level and event.from_room_id:
            event.cancel()
            self.world.move_entity(event.entity_id, event.from_room_id)

Pre-Move Gate (Prevent Entry)

To actually prevent movement before it happens, use one of the mechanisms that run before World.move_entity():

  • Lock-gated entrance command — the enter_dungeon command above. The lock is evaluated before the handler runs, so an under-leveled player never moves.
  • Exit doors / locks — the standard move command checks an exit's door state (locked/closed) and hidden flag before moving (maid_stdlib/commands/basic.py). See the Locked Door recipe for item-gated exits.
  • Command pre-hook — register a pre-hook filtered by categories=["movement"] (NOT commands=["move", "go"]). Movement is spread across many separately-registered commands — each compass direction (north/n, south/s, east, west, up, down, the diagonals) plus go (whose aliases include move and walk) — and they are all registered under the movement category (maid_stdlib/commands/__init__.py). There is no command literally named move, so a name filter like commands=["move", "go"] would silently miss every directional command. register_pre_hook(..., categories=["movement"]) covers all of them. The hook must still resolve the target exit itself to know the destination room, so for room-specific gating prefer the entrance-command or exit-lock approaches above; use the category pre-hook when you want a blanket movement gate.

Using a Custom Lock Function

Register a custom lock function for more complex gating:

from maid_engine.commands.locks import LockContext
from maid_engine.commands.registry import LayeredCommandRegistry


def lock_area_level(ctx: LockContext, args: list[str]) -> bool:
    """Check if the player meets the area's level requirement.

    Usage in lock expression: area_level()
    """
    if not ctx.player_entity_id:
        return False

    entity = ctx.world.get_entity(ctx.player_entity_id)
    if not entity:
        return False

    # Get the player's current room's minimum level
    room_id = ctx.world.get_entity_room(ctx.player_entity_id)
    if not room_id:
        return True  # No room = no restriction

    min_level = _room_min_level(ctx.world.get_room(room_id))
    if min_level <= 0:
        return True

    from maid_classic_rpg.components import CharacterInfoComponent
    info = entity.try_get(CharacterInfoComponent)
    return (info.level if info else 0) >= min_level


# Register inside your ContentPack's register_commands method:
def register_commands(self, registry: LayeredCommandRegistry) -> None:
    registry.register_lock_function("area_level", lock_area_level)

Then use it:

@command(
    name="explore",
    locks="area_level()",
    help_text="Explore the current area",
)
async def cmd_explore(ctx: CommandContext) -> bool:
    await ctx.session.send("You explore the area and discover hidden treasures!\n")
    return True

Builder Setup

Set a level requirement on a room in-game:

@set here/min_level = 10
@describe here The entrance to the Abyssal Depths. A sign reads: "DANGER - Level 10 required."

Room-type caveat: @set here/min_level = 10 works on dict-backed rooms (for example rooms loaded from YAML, which accept arbitrary keys). Rooms created in-game with @dig/@create room are RoomData records that only carry name/description/exits/area_id, and @set cannot add new fields to them (it returns "Cannot set min_level"). For those rooms, define min_level in the room's YAML instead. The _room_min_level() helper above reads the value the same way regardless of how the room was stored. There is no level_gate tag to add — the system keys off the numeric min_level field, not a room tag (and @attribute cannot tag rooms anyway).

How It Works

  1. char_level(n) lock function: Built-in — checks CharacterInfoComponent.level >= n (or any StatsComponent-style component carrying level) on the player
  2. Lock expressions run automatically before command execution; if they return False, the command is denied
  3. Event bounce-back: RoomEnterEvent is emitted after the move completes, so event.cancel() cannot prevent it — it only stops later handlers of that event. A reactive gate must explicitly move the player back
  4. Custom lock functions: Registered via registry.register_lock_function() in register_commands() for domain-specific checks

Variations

  • Soft gate: Instead of blocking, warn the player and apply a debuff (reduced stats in the area)
  • Quest gate: Use locks="has_flag(completed_trial)" — require completing a quest instead of a level
  • Combined: locks="char_level(10) AND has_item(dungeon_pass)" — both character level and an item required
  • Account/admin gate: locks="level(admin)" (or level(implementor)) — gates by the player's account access level instead of their character level. Use this for admin commands that should follow the player across characters.
  • Class restriction: Register a custom is_class(warrior) lock function
  • Progressive unlocking: Gate each floor of a dungeon at incrementally higher levels (10, 15, 20, etc.)

See Also