Skip to content

Content Pack System

MAID uses a content pack system to separate game content from engine infrastructure. Content packs can provide systems, commands, events, and data.

Content Pack Architecture

flowchart TB
    subgraph Pack["Content Pack"]
        M[Manifest<br/>name, version, deps]
        SY[Systems]
        CMD[Commands]
        EV[Events]
        SCH[Schemas]
        HL[Lifecycle Hooks]
    end

    subgraph Engine["Game Engine"]
        SM[System Manager]
        CR[Command Registry]
        EB[Event Bus]
        DS[Document Store]
    end

    M --> Engine
    SY --> SM
    CMD --> CR
    EV --> EB
    SCH --> DS
    HL --> Engine

Creating a Content Pack

from maid_engine.plugins.protocol import BaseContentPack
from maid_engine.plugins.manifest import ContentPackManifest

class MyContentPack(BaseContentPack):
    """Custom content pack example."""

    @property
    def manifest(self) -> ContentPackManifest:
        return ContentPackManifest(
            name="my-pack",
            version="1.0.0",
            display_name="My Pack",
            description="Custom game content",
            dependencies={"stdlib": ">=0.1.0"},  # Depends on stdlib
            provides=["custom-feature"],
            requires=["ecs", "event-bus"],
        )

    def get_dependencies(self) -> list[str]:
        """Packs that must be loaded before this one."""
        return ["stdlib"]

    def get_systems(self, world: World) -> list[System]:
        """ECS systems provided by this pack."""
        return [
            MyCustomSystem(world),
            AnotherSystem(world),
        ]

    def get_events(self) -> list[type[Event]]:
        """Event types defined by this pack."""
        return [MyCustomEvent, AnotherEvent]

    def register_commands(self, registry: CommandRegistry) -> None:
        """Commands provided by this pack."""
        registry.register(my_command)
        registry.register(another_command)

    def register_document_schemas(self, store: DocumentStore) -> None:
        """Document schemas for persistence."""
        store.register_schema("my_collection", MyModel)

    async def on_load(self, engine: GameEngine) -> None:
        """Initialize when pack is loaded."""
        # Load data files, connect to services, etc.
        pass

    async def on_unload(self, engine: GameEngine) -> None:
        """Cleanup when pack is unloaded."""
        # Save state, close connections, etc.
        pass

Loading Content Packs

from maid_engine.core.engine import GameEngine
from maid_stdlib.pack import StdlibContentPack
from maid_classic_rpg.pack import ClassicRPGContentPack

# Create engine
engine = GameEngine(settings)

# Load packs in dependency order
engine.load_content_pack(StdlibContentPack())
engine.load_content_pack(ClassicRPGContentPack())

# Start engine (calls on_load for each pack)
await engine.start()

Content Pack Discovery

Packs can be discovered automatically via Python entry points:

# In your pack's pyproject.toml
[project.entry-points."maid.content_packs"]
my-pack = "my_package.pack:MyContentPack"

Or from local directories:

from maid_engine.plugins.loader import discover_content_packs, ContentPackLoader

# Discover packs from entry points and directories
packs = discover_content_packs(
    search_paths=[Path("./custom_packs")],
    use_entry_points=True,
)

# Use loader for dependency resolution
loader = ContentPackLoader()
for pack in packs:
    loader.register(pack)

# Get packs in dependency order
ordered = loader.resolve_load_order()

Pack Dependency Graph

flowchart BT
    subgraph Available["Available Packs"]
        STD[stdlib]
        CRP[classic-rpg]
        MY[my-custom-pack]
    end

    CRP -->|depends on| STD
    MY -->|depends on| STD
    MY -.->|optional| CRP

ContentPack Protocol Extensions

In addition to the core lifecycle methods shown above, content packs can implement these optional registration hooks:

Component Type Registration

Register custom component types so the persistence system can serialize and deserialize them:

from maid_engine.core.ecs.registry import ComponentRegistry

def register_component_types(self, registry: ComponentRegistry) -> None:
    """Register component types for persistence serialization."""
    registry.register(MyCustomComponent)
    registry.register(AnotherComponent)

This is called during pack loading, before on_load. Each pack registers its custom components so that persisted entity data can be reconstructed on server restart.

Document Schema Registration

Register document collection schemas for pack-specific persistent data (e.g., NPC schedules, quest history, faction standings):

def register_document_schemas(self, store: DocumentStore) -> None:
    """Register document schemas for this pack's data."""
    store.register_schema("npc_goals", NPCGoalModel)
    store.register_schema("quest_history", QuestHistoryModel)

See the Persistence Guide for full details on both registration hooks.

Data Loader Integration

Content packs can provide YAML data files for declarative content authoring instead of (or alongside) programmatic entity creation. Packs that expose data files implement the DataLoaderPack protocol:

from maid_engine.loader.protocols import DataLoaderPack

class MyContentPack(BaseContentPack):
    # ... other methods ...

    def get_data_paths(self) -> list[Path]:
        """Directories containing YAML entity definitions."""
        return [Path(__file__).parent / "data"]

    def get_entity_type_configs(self) -> list[EntityTypeConfig]:
        """Supported entity types and their component mappings."""
        return [my_npc_config, my_item_config]

    def get_semantic_rules(self) -> list[SemanticRule]:
        """Validation rules for data files."""
        return [require_health_rule]

The loader pipeline processes data files through these phases:

  1. Discovery — Finds YAML files in get_data_paths() directories
  2. Parsing — Parses YAML with template inheritance and variable substitution
  3. Validation — Validates against component schemas and semantic rules
  4. Reference resolution — Resolves cross-entity @ref: references to UUIDs
  5. Instantiation — Creates entities with the resolved components
  6. Post-load hooks — Runs any post-load callbacks

Migration Support

Content packs can include database migrations for schema changes. Migrations are namespaced per pack and resolved in dependency order by MigrationRunner.

my_pack/
├── migrations/
│   ├── 001_initial.sql
│   └── 002_add_index.sql
└── pack.py
  • Namespaces follow the pack name (e.g., engine:, stdlib:, classic-rpg:)
  • Dependencies between pack migrations are resolved automatically based on pack dependency order
  • Use MigrationRunner.migrate(namespace="my-pack") to run a single pack's migrations, or omit the namespace to run all pending migrations
  • Dry-run mode is available for validation: migrate(dry_run=True)