Skip to content

Player Housing

Problem

You want players to claim a room as their personal house, lock the door so only they can enter, and place furniture inside.

Solution

The Ownership Component

from uuid import UUID
from pydantic import Field
from maid_engine.core.ecs import Component


class HouseOwnershipComponent(Component):
    """Marks a room as player-owned housing."""

    owner_id: UUID
    owner_name: str = ""
    allowed_visitors: list[UUID] = Field(default_factory=list)  # Players who can enter
    max_furniture: int = 10
    furniture_count: int = 0

Claiming a Room

from uuid import UUID
from maid_engine.commands.decorators import command
from maid_engine.commands import CommandContext
from maid_stdlib.components import (
    DescriptionComponent,
    MetadataComponent,
)


@command(
    name="claim",
    category="housing",
    help_text="Claim an unclaimed room as your house",
)
async def cmd_claim(ctx: CommandContext) -> bool:
    """Claim the current room as a player house."""
    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

    # Check if already claimed
    existing = room.try_get(HouseOwnershipComponent)
    if existing:
        await ctx.session.send(
            f"This room is already owned by {existing.owner_name}.\n"
        )
        return False

    # Must be tagged as claimable
    if not room.has_tag("claimable"):
        await ctx.session.send("This room cannot be claimed.\n")
        return False

    # Claim it
    player = ctx.world.get_entity(ctx.player_id)
    if not player:
        return False

    player_desc = player.try_get(DescriptionComponent)
    owner_name = player_desc.name if player_desc else "Unknown"

    room.add(HouseOwnershipComponent(
        owner_id=ctx.player_id,
        owner_name=owner_name,
    ))
    room.remove_tag("claimable")
    room.add_tag("player_house")

    # Access is enforced by HouseAccessSystem (below), which bounces
    # non-owner/non-visitor players back out. We deliberately do NOT set
    # ExitInfo.locked here: a locked door blocks *everyone* (including the
    # owner) at the movement door-check, and ExitInfo.key_id is not consulted
    # during movement — so a per-player "key" string cannot express an ACL.

    # Track ownership metadata
    meta = room.try_get(MetadataComponent)
    if meta:
        meta.record_modification(
            "owner", modified_by=owner_name, new_value=str(ctx.player_id),
        )

    await ctx.session.send(
        "You claim this room as your house! "
        "Only you and invited visitors may enter.\n"
    )
    return True

Placing Furniture

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_stdlib.components import (
    DescriptionComponent,
    InventoryComponent,
    ItemComponent,
    PositionComponent,
)


FURNITURE_TEMPLATES: dict[str, dict[str, str]] = {
    "chair": {
        "name": "Wooden Chair",
        "short_desc": "a sturdy wooden chair",
        "long_desc": "A simple but well-crafted wooden chair.",
    },
    "table": {
        "name": "Oak Table",
        "short_desc": "a solid oak table",
        "long_desc": "A heavy oak table with carved legs.",
    },
    "bed": {
        "name": "Comfortable Bed",
        "short_desc": "a comfortable-looking bed",
        "long_desc": "A bed with a thick mattress and warm blankets.",
    },
}


@command(
    name="furnish",
    category="housing",
    help_text="Place a piece of furniture in your house",
)
@arguments(
    ArgumentSpec("furniture", ArgumentType.STRING, description="Furniture type to place"),
)
async def cmd_furnish(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Place furniture in the player's house."""
    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

    ownership = room.try_get(HouseOwnershipComponent)
    if not ownership or ownership.owner_id != ctx.player_id:
        await ctx.session.send("You can only furnish your own house.\n")
        return False

    if ownership.furniture_count >= ownership.max_furniture:
        await ctx.session.send("Your house is full! Remove something first.\n")
        return False

    furniture_type: str = args["furniture"].lower()
    template = FURNITURE_TEMPLATES.get(furniture_type)
    if not template:
        types = ", ".join(FURNITURE_TEMPLATES.keys())
        await ctx.session.send(
            f"Unknown furniture type. Available: {types}\n"
        )
        return False

    # Create the furniture entity in the room
    furniture = ctx.world.create_entity()
    furniture.add(DescriptionComponent(
        name=template["name"],
        short_desc=template["short_desc"],
        long_desc=template["long_desc"],
        keywords=[furniture_type, "furniture"],
    ))
    furniture.add(ItemComponent(item_type="furniture", weight=50.0))
    # Durable location: place_entity_in_room() only updates a PositionComponent
    # if one exists, and the persisted room is derived from it. Without this the
    # furniture would vanish from the house on reload.
    furniture.add(PositionComponent(room_id=room_id))
    furniture.add_tag("item")
    furniture.add_tag("furniture")
    ctx.world.place_entity_in_room(furniture.id, room_id)

    ownership.furniture_count += 1
    ownership.notify_mutation()

    await ctx.session.send(
        f"You place a {template['name']} in your house.\n"
    )
    return True


@command(
    name="unfurnish",
    aliases=["remove_furniture"],
    category="housing",
    help_text="Remove a piece of furniture from your house",
)
@arguments(
    ArgumentSpec("target", ArgumentType.STRING, description="Furniture to remove"),
)
async def cmd_unfurnish(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Remove furniture from the player's house."""
    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

    ownership = room.try_get(HouseOwnershipComponent)
    if not ownership or ownership.owner_id != ctx.player_id:
        await ctx.session.send("You can only modify your own house.\n")
        return False

    keyword: str = args["target"]
    for entity in ctx.world.entities_in_room(room_id):
        if not entity.has_tag("furniture"):
            continue
        desc = entity.try_get(DescriptionComponent)
        if desc and desc.matches_keyword(keyword):
            name = desc.name
            ctx.world.destroy_entity(entity.id)
            ownership.furniture_count -= 1
            ownership.notify_mutation()
            await ctx.session.send(f"You remove the {name}.\n")
            return True

    await ctx.session.send(f"No furniture matching '{keyword}' here.\n")
    return False

Allowing Visitors

from maid_engine.commands.decorators import command, arguments
from maid_engine.commands.arguments import ArgumentSpec, ArgumentType, ParsedArguments
from maid_engine.commands import CommandContext


@command(
    name="allow",
    category="housing",
    help_text="Allow a player to enter your house",
)
@arguments(
    ArgumentSpec("player", ArgumentType.STRING, description="Player name to allow"),
)
async def cmd_allow_visitor(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Add a player to the allowed visitors list."""
    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

    ownership = room.try_get(HouseOwnershipComponent)
    if not ownership or ownership.owner_id != ctx.player_id:
        await ctx.session.send("You can only manage your own house.\n")
        return False

    player_name: str = args["player"]

    # Find the player entity by name
    for entity in ctx.world.get_all_entities():
        if not entity.has_tag("player"):
            continue
        from maid_stdlib.components import DescriptionComponent
        desc = entity.try_get(DescriptionComponent)
        if desc and desc.name.lower() == player_name.lower():
            if entity.id not in ownership.allowed_visitors:
                ownership.allowed_visitors.append(entity.id)
                ownership.notify_mutation()
            await ctx.session.send(
                f"{desc.name} can now enter your house.\n"
            )
            return True

    await ctx.session.send(f"Player '{player_name}' not found.\n")
    return False

Enforcing Access

The allowed_visitors list is only an ACL — something has to enforce it. Register a system that watches room entries and bounces unauthorized players back out:

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


class HouseAccessSystem(System):
    """Bounces non-owner/non-visitor players out of claimed houses."""

    priority = 5  # Run early

    async def startup(self) -> None:
        # Store the handler id so shutdown() can unsubscribe; a leaked handler
        # would keep firing against a torn-down system after a hot-reload.
        self._enter_sub = self.events.subscribe(RoomEnterEvent, self._enforce_access)

    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 _enforce_access(self, event: RoomEnterEvent) -> None:
        room = self.world.get_entity(event.room_id)
        if not room:
            return
        ownership = room.try_get(HouseOwnershipComponent)
        if not ownership:
            return  # Not a house — no restriction

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

        if (
            event.entity_id == ownership.owner_id
            or event.entity_id in ownership.allowed_visitors
        ):
            return  # Authorized

        # Unauthorized: bounce back where they came from.
        if event.from_room_id:
            self.world.move_entity(event.entity_id, event.from_room_id)

RoomEnterEvent fires after the move completes. As with the Level Gating recipe, World.move_entity() performs the move before emitting RoomEnterEvent, so this system is a reactive bounce-back: the intruder briefly enters, then is moved back. event.cancel() would not undo the move. Do not set ExitInfo.locked = True on the house's entrance to "help" — that blocks the owner too (the door-check runs before movement) and the enter event never fires. For a hard pre-move gate, gate a dedicated enter <house> command with a custom is_owner_or_visitor() lock function instead (see Variations).

Return HouseAccessSystem from your content pack's get_systems() so it runs each tick's event processing. Also register the custom HouseOwnershipComponent for persistence in register_component_types, or claimed houses lose their owner and visitor list on restart:

from maid_engine.persistence.registry import ComponentRegistry

def register_component_types(self, registry: ComponentRegistry) -> None:
    registry.register(HouseOwnershipComponent, pack_name="my-pack")

Setting Up Claimable Rooms

During your content pack's on_load, prepare rooms for housing:

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


async def create_claimable_house(world: World, district_room_id: UUID) -> UUID:
    """Create a single claimable house reachable from the district.

    Returns the new house's room id.

    Two hard constraints this illustrates:

    * **Exits must use real compass directions.** The movement command only
      accepts directions in ``ALL_DIRECTIONS`` (``n/s/e/w/up/down`` and the
      diagonals); ``go out`` / ``go in`` are rejected as "not a valid
      direction" and there is no standalone ``out``/``in`` command. So the
      house is entered via ``north`` and left via ``south`` — never ``in``/
      ``out``, which would build an exit no player could ever traverse.
    * **This topology is volatile.** Rooms registered with
      ``world.register_room`` and their exit dicts live only in memory. Entity
      persistence saves entity *components and tags*, not the World's room
      registry or exit graph (see the Locked Door recipe), so this district and
      its links are lost on restart. For a durable, multi-house neighborhood,
      author the district and house shells in YAML with stable ids and explicit
      bidirectional exits, and do only claiming / ownership / furnishing at
      runtime. (A single district room can hold only a handful of compass exits
      anyway, so YAML is the right tool for a real neighborhood.)
    """
    house = world.create_entity()
    house.add(DescriptionComponent(
        name="Empty House",
        short_desc="an empty house waiting for an owner",
        long_desc="Bare walls and dusty floors. This house needs a tenant.",
        keywords=["house", "empty"],
    ))
    house.add(ExitMetadataComponent(
        exits={
            "south": ExitInfo(door=True, door_name="front door"),
        },
    ))
    house.add_tag("room")
    house.add_tag("claimable")
    world.register_room(house.id, {"name": "Empty House", "exits": {}})
    world.place_entity_in_room(house.id, district_room_id)

    # Wire a traversable, bidirectional link using real compass directions:
    #   district --north--> house        house --south--> district
    # Both sides must be dict-backed rooms for the exit keys to be added.
    house_data = world.get_room(house.id)
    if isinstance(house_data, dict):
        house_data.setdefault("exits", {})["south"] = district_room_id
    district_data = world.get_room(district_room_id)
    if isinstance(district_data, dict):
        district_data.setdefault("exits", {})["north"] = house.id

    return house.id

Builder Commands

You can also set up claimable rooms in-game:

@create room Empty Cottage
@describe here A cozy cottage with a stone fireplace. Unclaimed.
@attribute #<room-uuid> add claimable
@dig south = Housing District --door

Use a compass direction, not in/out. @dig south = Housing District digs a room to the south and (because @dig makes a two-way exit) auto-creates the reverse north exit, so players enter the cottage from the district by typing north. @dig out = ... would create an exit keyed out that the movement command rejects (go out → "not a valid direction"), so no one could walk through it.

Why #<room-uuid> and not here? cmd_claim above reads the "claimable" tag from the room's room-ref entity (ctx.world.get_entity(room_id)), which shares the room's UUID. @attribute is entity-only and rejects a room target, so @attribute here add claimable fails. Instead, tag the room-ref entity by its UUID — @create room prints it ("Created room '…' with ID: <uuid>"), and @dig-created rooms also register a room-ref entity with the room's ID. Note that @create room reuses your current room's ID (it renames the room you are standing in) and does not move you; run it from a throwaway room.

@dig ... --door records the door on the room's ExitMetadataComponent; there is no supported @set here/exits/south/... path (a room entity has no exits attribute). See the Locked Door recipe for the exit-metadata details.

How It Works

  1. HouseOwnershipComponent tracks the owner, allowed visitors, and furniture count
  2. Rooms tagged claimable can be claimed with the claim command, which records ownership (it does not lock the exit)
  3. HouseAccessSystem enforces the ACL: it watches RoomEnterEvent and bounces any player who is not the owner or an allowed visitor back out (a reactive, post-move bounce-back — ExitInfo.key_id is not an access mechanism)
  4. furnish creates furniture entities and places them in the room via world.place_entity_in_room()
  5. notify_mutation() marks changed components dirty for persistence
  6. The allow command adds player UUIDs to allowed_visitors, which HouseAccessSystem reads

Variations

  • Rent system: Charge gold per game-day using a RecurringTimerSystem; evict if the player can't pay
  • Upgrades: Track a house_tier and unlock more max_furniture slots at higher tiers
  • Storage chest: Add a container entity with its own InventoryComponent for item storage
  • Neighborhood bonuses: Grant buffs when multiple houses in an area are claimed
  • Hard pre-move gate: Enter the house via a dedicated enter <house> command guarded by a custom is_owner_or_visitor() lock function, so unauthorized players never move at all (instead of the reactive bounce-back)

See Also