Skip to content

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 reboot time.monotonic() can be smaller than the stored value, making now - last_used negative 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 PositionComponent for durability. @create item gives the item only DescriptionComponent and ItemComponentnot a PositionComponent. @teleport (like world.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 a PositionComponent would vanish from the room on restart. Add the component first (with the room's UUID — @stat here / the @create room output shows it), then @teleport keeps it in sync. <this-room-uuid> is the room you're standing in.

How It Works

  1. PortalComponent stores the destination and activation rules
  2. cmd_enter_portal finds a matching portal in the room, checks the cooldown and key requirement, then calls world.move_entity()
  3. world.move_entity() handles updating PositionComponent and room index automatically
  4. The cooldown uses time.monotonic(); the last-use time is reserved in the transient _portal_last_used dict before the teleport await, so a concurrent second enter during the await can't bypass it. It is not persisted (a monotonic value is meaningless across restarts).
  5. 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 requires WorldManager.transition_player(player_id, from_world, to_world, to_room), which recreates the player (and inventory) in the target world. WorldManager is not reachable from CommandContext (there is no ctx.world.engine.world_manager), so a portal command can't call it directly — you must wire the WorldManager into 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 (or locks="level(admin)" for an account/admin gate)
  • Portal network: Create a PortalNetworkSystem that links multiple portals like a fast-travel map
  • Visual effects: Subscribe to a custom PortalActivatedEvent to trigger room description changes

See Also