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'sStatsComponent.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'sAccessLevel). 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-tiedlevel()change it now reads the account; the dedicated character-level function ischar_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)¶
RoomEnterEventfires after the move completes.
World.move_entity()updates the room index and the entity'sPositionComponentbefore it emitsRoomLeaveEvent/RoomEnterEvent(core/world.py). Callingevent.cancel()inside aRoomEnterEventhandler 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_dungeoncommand above. The lock is evaluated before the handler runs, so an under-leveled player never moves. - Exit doors / locks — the standard
movecommand checks an exit's door state (locked/closed) andhiddenflag 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"](NOTcommands=["move", "go"]). Movement is spread across many separately-registered commands — each compass direction (north/n,south/s,east,west,up,down, the diagonals) plusgo(whose aliases includemoveandwalk) — and they are all registered under themovementcategory (maid_stdlib/commands/__init__.py). There is no command literally namedmove, so a name filter likecommands=["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 = 10works on dict-backed rooms (for example rooms loaded from YAML, which accept arbitrary keys). Rooms created in-game with@dig/@create roomareRoomDatarecords that only carryname/description/exits/area_id, and@setcannot add new fields to them (it returns "Cannot set min_level"). For those rooms, definemin_levelin 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 nolevel_gatetag to add — the system keys off the numericmin_levelfield, not a room tag (and@attributecannot tag rooms anyway).
How It Works¶
char_level(n)lock function: Built-in — checksCharacterInfoComponent.level >= n(or anyStatsComponent-style component carryinglevel) on the player- Lock expressions run automatically before command execution; if they return
False, the command is denied - Event bounce-back:
RoomEnterEventis emitted after the move completes, soevent.cancel()cannot prevent it — it only stops later handlers of that event. A reactive gate must explicitly move the player back - Custom lock functions: Registered via
registry.register_lock_function()inregister_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)"(orlevel(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¶
- Locked Door — Item-based access gating
- Command System Guide — Lock expressions reference
- Hidden Room — Another access pattern