Teleport Portal¶
Problem¶
You want a portal object that teleports players from one room to another when they interact with it — optionally across different worlds.
Solution¶
The Portal Component¶
from uuid import UUID
from maid_engine.core.ecs import Component
class PortalComponent(Component):
"""Marks an entity as a teleportation portal."""
destination_room_id: UUID
portal_name: str = "shimmering portal"
use_message: str = "You step through the portal..."
arrive_message: str = "{name} steps out of a portal."
cooldown: float = 0.0 # Seconds between uses (0 = no cooldown)
requires_key: str | None = None # Item keyword needed to activate
Cooldown state is deliberately not a field here. A cooldown timestamp is transient, process-local runtime state — not durable configuration. We track it with
time.monotonic(), whose zero point is arbitrary and resets every time the process restarts. Persisting a monotonic value would be meaningless (after a reboottime.monotonic()can be smaller than the stored value, makingnow - last_usednegative and the portal look permanently on cooldown). So we keep it in an in-memory dict that resets on restart — exactly what you want for a short anti-spam gate.
import time
from uuid import UUID
# Transient, in-memory cooldown state keyed by portal entity id. Intentionally
# NOT persisted: monotonic timestamps do not survive a process restart.
_portal_last_used: dict[UUID, float] = {}
The Enter Command¶
import time
from uuid import UUID
from maid_engine.commands.decorators import command, arguments
from maid_engine.commands.arguments import ArgumentSpec, ArgumentType, ParsedArguments
from maid_engine.commands import CommandContext
from maid_engine.core.ecs import Entity
from maid_stdlib.components import DescriptionComponent
def _find_portal(ctx: CommandContext, keyword: str) -> Entity | None:
"""Find a portal entity in the player's room."""
room_id = ctx.world.get_entity_room(ctx.player_id)
if not room_id:
return None
for entity in ctx.world.entities_in_room(room_id):
portal = entity.try_get(PortalComponent)
if not portal:
continue
desc = entity.try_get(DescriptionComponent)
if desc and desc.matches_keyword(keyword):
return entity
if keyword.lower() in portal.portal_name.lower():
return entity
return None
@command(name="enter", aliases=["portal"], category="movement",
help_text="Enter a portal to teleport")
@arguments(
ArgumentSpec("target", ArgumentType.STRING, required=False, default="portal"),
)
async def cmd_enter_portal(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Step through a portal to teleport to another location."""
keyword: str = args["target"]
portal_entity = _find_portal(ctx, keyword)
if not portal_entity:
await ctx.session.send("There's no portal here.\n")
return False
portal = portal_entity.get(PortalComponent)
# Enforce the portal cooldown using a monotonic clock (immune to wall-clock
# changes). The last-use time lives in the transient _portal_last_used dict,
# not on the persisted component (see note above).
now = time.monotonic()
last_used = _portal_last_used.get(portal_entity.id, 0.0)
if portal.cooldown > 0 and now - last_used < portal.cooldown:
remaining = int(portal.cooldown - (now - last_used)) + 1
await ctx.session.send(
f"The portal is still recharging ({remaining}s).\n"
)
return False
# Check key requirement
if portal.requires_key:
player = ctx.world.get_entity(ctx.player_id)
if not player:
return False
from maid_stdlib.components import InventoryComponent
inv = player.try_get(InventoryComponent)
has_key = False
if inv:
for item_id in inv.items:
item = ctx.world.get_entity(item_id)
if item:
desc = item.try_get(DescriptionComponent)
if desc and desc.matches_keyword(portal.requires_key):
has_key = True
break
if not has_key:
await ctx.session.send(
"The portal resists your touch. You seem to need something.\n"
)
return False
# Verify destination exists
dest_room = ctx.world.get_room(portal.destination_room_id)
if not dest_room:
await ctx.session.send("The portal flickers but leads nowhere.\n")
return False
# Reserve the cooldown NOW, before the first await below. All rejection
# paths (cooldown, key, missing destination) above are synchronous, so no
# cooldown is consumed on failure. Reserving before we yield prevents a
# second, concurrent `enter` from slipping past the check while this call is
# suspended at an await.
_portal_last_used[portal_entity.id] = now
# Announce departure
from_room_id = ctx.world.get_entity_room(ctx.player_id)
await ctx.session.send(f"{portal.use_message}\n")
# Teleport
ctx.world.move_entity(ctx.player_id, portal.destination_room_id)
# Show the new room (reuse look command pattern)
await ctx.session.send("You arrive in a new location.\n")
return True
Creating a Portal Pair¶
from uuid import UUID
from maid_engine.core.world import World
from maid_stdlib.components import DescriptionComponent, PositionComponent
async def create_portal_pair(
world: World,
room_a: UUID,
room_b: UUID,
) -> None:
"""Create a bidirectional portal between two rooms."""
# Portal A → B
portal_ab = world.create_entity()
portal_ab.add(DescriptionComponent(
name="Blue Portal",
short_desc="a shimmering blue portal",
long_desc="A swirling vortex of blue energy hangs in the air.",
keywords=["blue", "portal"],
))
portal_ab.add(PortalComponent(
destination_room_id=room_b,
portal_name="blue portal",
use_message="You step into the blue vortex. The world spins...",
arrive_message="{name} tumbles out of a blue portal.",
))
portal_ab.add(PositionComponent(room_id=room_a)) # durable location
portal_ab.add_tag("portal")
world.place_entity_in_room(portal_ab.id, room_a)
# Portal B → A (return portal)
portal_ba = world.create_entity()
portal_ba.add(DescriptionComponent(
name="Red Portal",
short_desc="a shimmering red portal",
long_desc="A swirling vortex of red energy hangs in the air.",
keywords=["red", "portal"],
))
portal_ba.add(PortalComponent(
destination_room_id=room_a,
portal_name="red portal",
use_message="You step into the red vortex. The world spins...",
arrive_message="{name} tumbles out of a red portal.",
))
portal_ba.add(PositionComponent(room_id=room_b)) # durable location
portal_ba.add_tag("portal")
world.place_entity_in_room(portal_ba.id, room_b)
Builder Commands¶
You can also create portals in-game:
@create item Blue Portal
@describe Blue Portal = A swirling vortex of blue energy.
@component Blue Portal add PortalComponent {"destination_room_id": "<target-room-uuid>", "portal_name": "blue portal"}
@component Blue Portal add PositionComponent {"room_id": "<this-room-uuid>"}
@teleport Blue Portal here
Add
PositionComponentfor durability.@create itemgives the item onlyDescriptionComponentandItemComponent— not aPositionComponent.@teleport(likeworld.move_entity/place_entity_in_room) updates a position component only if the entity already has one, otherwise it just updates the in-memory room index. Since persistence saves components (EntityDocument), a portal without aPositionComponentwould vanish from the room on restart. Add the component first (with the room's UUID —@stat here/ the@create roomoutput shows it), then@teleportkeeps it in sync.<this-room-uuid>is the room you're standing in.
How It Works¶
- PortalComponent stores the destination and activation rules
cmd_enter_portalfinds a matching portal in the room, checks the cooldown and key requirement, then callsworld.move_entity()world.move_entity()handles updatingPositionComponentand room index automatically- The cooldown uses
time.monotonic(); the last-use time is reserved in the transient_portal_last_useddict before the teleport await, so a concurrent secondenterduring the await can't bypass it. It is not persisted (a monotonic value is meaningless across restarts). - For bidirectional travel, create two portal entities — one in each room
Register PortalComponent for persistence in your content pack so a portal's
durable configuration (destination, cooldown length, key requirement) survives
a restart. The runtime cooldown timer is intentionally kept out of the
component, so nothing invalid is persisted:
from maid_engine.persistence.registry import ComponentRegistry
def register_component_types(self, registry: ComponentRegistry) -> None:
registry.register(PortalComponent, pack_name="my-pack")
Variations¶
- Cross-world portals: This recipe moves players within a single
World. True cross-world travel requiresWorldManager.transition_player(player_id, from_world, to_world, to_room), which recreates the player (and inventory) in the target world.WorldManageris not reachable fromCommandContext(there is noctx.world.engine.world_manager), so a portal command can't call it directly — you must wire theWorldManagerinto your system/command yourself. See Multi-World Support for the honest capabilities and limitations. - Timed portals: Add an expiry timestamp — a system removes the portal after N minutes
- Random destination: Pick from a list of
destination_room_ids each time - Level-gated portal: Add
locks="char_level(10)"to restrict portal use by character level (orlocks="level(admin)"for an account/admin gate) - Portal network: Create a
PortalNetworkSystemthat links multiple portals like a fast-travel map - Visual effects: Subscribe to a custom
PortalActivatedEventto trigger room description changes
See Also¶
- Level Gating — Restricting access by character level
- Building Commands —
@create,@component - Multi-World Support — Cross-world teleportation