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¶
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¶
- Systems Guide - Deep dive into writing systems
- Commands Guide - Advanced command features
- Events Guide - Working with the event bus
- Persistence Guide - Storing and loading data
- Testing Guide - Testing your content pack