Skip to content

Creating Content Packs

This guide covers everything you need to know to create a content pack from scratch.

Project Structure

A typical content pack has the following structure:

my-content-pack/
    src/
        my_content_pack/
            __init__.py
            pack.py
            components/
                __init__.py
                core.py
            systems/
                __init__.py
                my_system.py
            commands/
                __init__.py
                my_commands.py
            events/
                __init__.py
                my_events.py
    tests/
        __init__.py
        test_systems.py
        test_commands.py
    pyproject.toml
    README.md

Setting Up the Project

pyproject.toml

[project]
name = "maid-my-content-pack"
version = "0.1.0"
description = "My custom content pack for MAID"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "maid-engine>=0.1.0",
    "maid-stdlib>=0.1.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.23",
]

[project.entry-points."maid.content_packs"]
my-content-pack = "my_content_pack:MyContentPack"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/my_content_pack"]

The Content Pack Class

The main entry point is your content pack class. It implements the ContentPack protocol:

# src/my_content_pack/pack.py
from __future__ import annotations

from typing import TYPE_CHECKING

from maid_engine.plugins.protocol import BaseContentPack

if TYPE_CHECKING:
    from maid_engine.commands.registry import CommandRegistry, LayeredCommandRegistry
    from maid_engine.core.ecs.system import System
    from maid_engine.core.engine import GameEngine
    from maid_engine.core.events import Event
    from maid_engine.core.world import World
    from maid_engine.plugins.manifest import ContentPackManifest
    from maid_engine.storage.document_store import DocumentStore

    AnyCommandRegistry = CommandRegistry | LayeredCommandRegistry


class MyContentPack(BaseContentPack):
    """My custom content pack."""

    @property
    def manifest(self) -> ContentPackManifest:
        """Return pack metadata."""
        from maid_engine.plugins.manifest import ContentPackManifest

        return ContentPackManifest(
            name="my-content-pack",
            version="0.1.0",
            display_name="My Content Pack",
            description="Custom game content for MAID",
            dependencies={"stdlib": ">=0.1.0"},
            provides=["my-feature"],
            requires=["ecs", "event-bus"],
            authors=["Your Name"],
            license="MIT",
        )

    def get_dependencies(self) -> list[str]:
        """Return list of required content pack names."""
        return ["stdlib"]

    def get_systems(self, world: World) -> list[System]:
        """Return systems to register with the engine."""
        from .systems import MySampleSystem

        return [MySampleSystem(world)]

    def get_events(self) -> list[type[Event]]:
        """Return event types defined by this pack."""
        from .events import MyCustomEvent

        return [MyCustomEvent]

    def register_commands(self, registry: AnyCommandRegistry) -> None:
        """Register commands with the command registry."""
        from .commands import register_commands

        register_commands(registry, pack_name=self.manifest.name)

    def register_document_schemas(self, store: DocumentStore) -> None:
        """Register document schemas for persistence."""
        from .models import MyDataModel

        store.register_schema("my_collection", MyDataModel)

    async def on_load(self, engine: GameEngine) -> None:
        """Called when the pack is loaded."""
        # Initialize resources
        # Load configuration
        # Start background tasks
        pass

    async def on_unload(self, engine: GameEngine) -> None:
        """Called when the pack is unloaded."""
        # Save state
        # Clean up resources
        # Stop background tasks
        pass

Using BaseContentPack

The BaseContentPack class provides sensible defaults, so you only need to override what you use:

from maid_engine.plugins.protocol import BaseContentPack


class MinimalPack(BaseContentPack):
    """A minimal content pack."""

    @property
    def manifest(self) -> ContentPackManifest:
        from maid_engine.plugins.manifest import ContentPackManifest

        return ContentPackManifest(
            name="minimal-pack",
            version="0.1.0",
        )

    # All other methods have default implementations that return
    # empty lists or do nothing

Manifest Configuration

The manifest describes your pack's metadata and requirements.

Required Fields

ContentPackManifest(
    name="my-pack",      # Unique identifier
    version="0.1.0",     # Semantic version
)

Optional Fields

ContentPackManifest(
    name="my-pack",
    version="0.1.0",
    display_name="My Pack",           # Human-readable name
    description="Pack description",    # What this pack does
    dependencies={                     # Required packs
        "stdlib": ">=0.1.0",
    },
    provides=["feature-a", "feature-b"],  # Capabilities provided
    requires=["ecs", "event-bus"],        # Capabilities required
    authors=["Author Name"],
    license="MIT",
    homepage="https://github.com/user/repo",
    keywords=["mud", "rpg", "combat"],
)

Version Constraints

Dependencies support various version constraint formats:

Constraint Meaning
>=0.1.0 Version 0.1.0 or higher
>0.1.0 Greater than 0.1.0
<=1.0.0 Version 1.0.0 or lower
<1.0.0 Less than 1.0.0
==1.0.0 Exactly version 1.0.0
>=0.1.0,<1.0.0 Range (not yet supported)

Defining Components

Components are data containers attached to entities:

# src/my_content_pack/components/core.py
from maid_engine.core.ecs import Component
from pydantic import Field
from uuid import UUID


class ReputationComponent(Component):
    """Tracks entity reputation with factions."""

    faction_standing: dict[str, int] = Field(default_factory=dict)

    def get_standing(self, faction: str) -> int:
        """Get standing with a faction (0 if unknown)."""
        return self.faction_standing.get(faction, 0)

    def modify_standing(self, faction: str, amount: int) -> int:
        """Modify standing with a faction, return new value."""
        current = self.get_standing(faction)
        new_value = max(-1000, min(1000, current + amount))
        self.faction_standing[faction] = new_value
        return new_value


class QuestLogComponent(Component):
    """Tracks active and completed quests."""

    active_quests: list[UUID] = Field(default_factory=list)
    completed_quests: list[UUID] = Field(default_factory=list)
    quest_progress: dict[str, int] = Field(default_factory=dict)

    def has_quest(self, quest_id: UUID) -> bool:
        """Check if quest is active."""
        return quest_id in self.active_quests

    def complete_quest(self, quest_id: UUID) -> bool:
        """Mark quest as completed."""
        if quest_id in self.active_quests:
            self.active_quests.remove(quest_id)
            self.completed_quests.append(quest_id)
            return True
        return False

Defining Events

Events enable communication between systems:

# src/my_content_pack/events/core.py
from dataclasses import dataclass
from uuid import UUID

from maid_engine.core.events import Event


@dataclass
class QuestAcceptedEvent(Event):
    """Emitted when a player accepts a quest."""

    player_id: UUID
    quest_id: UUID
    quest_giver_id: UUID | None = None


@dataclass
class QuestCompletedEvent(Event):
    """Emitted when a player completes a quest."""

    player_id: UUID
    quest_id: UUID
    rewards_granted: bool = False


@dataclass
class ReputationChangedEvent(Event):
    """Emitted when reputation changes."""

    entity_id: UUID
    faction: str
    old_value: int
    new_value: int
    reason: str = ""

Defining Systems

Systems contain game logic that runs each tick:

# src/my_content_pack/systems/quest_system.py
from maid_engine.core.ecs import System

from ..components import QuestLogComponent
from ..events import QuestCompletedEvent


class QuestTrackingSystem(System):
    """Tracks quest progress and completion."""

    priority = 100  # Run after most systems

    async def startup(self) -> None:
        """Subscribe to relevant events."""
        # Could subscribe to combat events to track kill quests, etc.
        pass

    async def update(self, delta: float) -> None:
        """Check for quest completion conditions."""
        for entity in self.entities.with_components(QuestLogComponent):
            quest_log = entity.get(QuestLogComponent)

            # Check each active quest
            for quest_id in list(quest_log.active_quests):
                if self._check_quest_complete(entity, quest_id):
                    quest_log.complete_quest(quest_id)

                    # Emit completion event
                    await self.events.emit(QuestCompletedEvent(
                        player_id=entity.id,
                        quest_id=quest_id,
                    ))

    def _check_quest_complete(self, entity, quest_id) -> bool:
        """Check if quest completion conditions are met."""
        # Implement quest completion logic
        return False

    async def shutdown(self) -> None:
        """Clean up when system shuts down."""
        pass

Defining Commands

Commands let players interact with your pack:

# src/my_content_pack/commands/quest_commands.py
from maid_engine.commands.registry import (
    AccessLevel,
    CommandContext,
    LayeredCommandRegistry,
)

from ..components import QuestLogComponent
from ..events import QuestAcceptedEvent


async def quest_command(ctx: CommandContext) -> bool:
    """View quest log or quest details."""
    player = ctx.world.entities.get(ctx.player_id)
    if not player:
        return False

    quest_log = player.try_get(QuestLogComponent)
    if not quest_log:
        await ctx.session.send("You don't have a quest log!")
        return True

    if not ctx.args:
        # Show quest list
        if quest_log.active_quests:
            await ctx.session.send("Active Quests:")
            for quest_id in quest_log.active_quests:
                await ctx.session.send(f"  - Quest {quest_id}")
        else:
            await ctx.session.send("You have no active quests.")
        return True

    # Show specific quest details
    quest_name = ctx.args[0]
    # ... implement quest lookup
    await ctx.session.send(f"Quest details for: {quest_name}")
    return True


async def accept_quest_command(ctx: CommandContext) -> bool:
    """Accept a quest from an NPC."""
    if not ctx.args:
        await ctx.session.send("Usage: accept <quest>")
        return True

    # Implementation...
    return True


def register_commands(registry: LayeredCommandRegistry, pack_name: str) -> None:
    """Register all quest commands."""
    registry.register(
        name="quest",
        handler=quest_command,
        pack_name=pack_name,
        aliases=["quests", "q"],
        category="quests",
        description="View your quest log",
        usage="quest [quest_name]",
    )

    registry.register(
        name="accept",
        handler=accept_quest_command,
        pack_name=pack_name,
        category="quests",
        description="Accept a quest",
        usage="accept <quest>",
    )

Defining Data Models

For persistence, define Pydantic models:

# src/my_content_pack/models.py
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, Field


class QuestModel(BaseModel):
    """Persisted quest data."""

    id: UUID
    name: str
    description: str
    objectives: list[str] = Field(default_factory=list)
    rewards: dict[str, int] = Field(default_factory=dict)
    required_level: int = 1
    repeatable: bool = False
    created_at: datetime = Field(default_factory=datetime.utcnow)

Package Init Files

Export your public API:

# src/my_content_pack/__init__.py
"""My content pack for MAID."""

from .pack import MyContentPack

__all__ = ["MyContentPack"]
# src/my_content_pack/components/__init__.py
from .core import QuestLogComponent, ReputationComponent

__all__ = ["QuestLogComponent", "ReputationComponent"]
# src/my_content_pack/events/__init__.py
from .core import QuestAcceptedEvent, QuestCompletedEvent, ReputationChangedEvent

__all__ = [
    "QuestAcceptedEvent",
    "QuestCompletedEvent",
    "ReputationChangedEvent",
]

Lifecycle Hooks

Use lifecycle hooks for setup and teardown:

async def on_load(self, engine: GameEngine) -> None:
    """Initialize the pack."""
    # Load configuration
    self._config = self._load_config()

    # Subscribe to engine events
    engine.world.events.subscribe(SomeEvent, self._handle_event)

    # Initialize caches
    self._quest_cache = {}

    # Start background tasks
    self._cleanup_task = asyncio.create_task(self._periodic_cleanup())

    print(f"{self.manifest.display_name} loaded!")


async def on_unload(self, engine: GameEngine) -> None:
    """Clean up the pack."""
    # Cancel background tasks
    if self._cleanup_task:
        self._cleanup_task.cancel()

    # Save any cached data
    await self._save_cache()

    # Clear caches
    self._quest_cache.clear()

    print(f"{self.manifest.display_name} unloaded!")

Next Steps