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,
    ExitMetadataComponent,
    ExitInfo,
    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")

    # Lock the entrance so only the owner can enter
    exit_meta = room.try_get(ExitMetadataComponent)
    if exit_meta:
        for direction, exit_info in exit_meta.exits.items():
            exit_info.door = True
            exit_info.locked = True
            exit_info.key_id = f"house_key_{ctx.player_id}"
        exit_meta.notify_mutation()

    # 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(
        f"You claim this room as your house! "
        f"The door is now locked to others.\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))
    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

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_housing_district(world: World, district_room_id: UUID) -> None:
    """Create several claimable rooms connected to a housing district."""
    for i in range(5):
        house = world.create_entity()
        house.add(DescriptionComponent(
            name=f"Empty House #{i + 1}",
            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={
                "out": ExitInfo(
                    door=True,
                    door_name="front door",
                ),
            },
        ))
        house.add_tag("room")
        house.add_tag("claimable")
        world.register_room(house.id, {"name": f"Player House #{house.id}"})
        world.place_entity_in_room(house.id, district_room_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 here add claimable
@dig out = Housing District
@set here/exits/out/door = true
@set here/exits/out/door_name = wooden door

How It Works

  1. HouseOwnershipComponent tracks the owner, allowed visitors, and furniture count
  2. Rooms tagged claimable can be claimed with the claim command
  3. Claiming locks the entrance using ExitMetadataComponent with a player-specific key_id
  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 the visitor list for access control

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
  • House lock expression: Register a custom is_owner_or_visitor() lock function to gate the entrance command instead of using key_id

See Also