Skip to content

Magic System Tutorial

This tutorial guides you through building a complete magic system content pack for MAID. You will learn how to create spell components, implement mana management, handle spell effects, and integrate with the combat system.

What We're Building

The magic system will include:

  • ManaComponent: Resource pool for casting spells
  • Spell: Definition of individual spells (a data class, not a component)
  • SpellbookComponent: Collection of learned spells
  • MagicSystem: Spell casting, mana regeneration, spell effects
  • Commands: cast, spells, learn commands
  • Effects: Damage, healing, buffs, debuffs

Prerequisites

Before starting this tutorial, you should:

  • Have completed the Combat System Tutorial
  • Understand MAID's ECS architecture
  • Be familiar with event-driven systems

Project Structure

maid-magic-system/
    src/
        maid_magic_system/
            __init__.py
            pack.py
            components/
                __init__.py
                mana.py
                spells.py
            systems/
                __init__.py
                magic_system.py
                mana_regen_system.py
            commands/
                __init__.py
                magic_commands.py
            events/
                __init__.py
                magic_events.py
            data/
                spells.py          # Spell definitions
    tests/
        conftest.py
        test_components.py
        test_system.py
        test_commands.py
    pyproject.toml

Part 1: Mana Component

The ManaComponent tracks magical energy:

# src/maid_magic_system/components/mana.py
"""Mana component for magical energy management."""

from typing import ClassVar

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


class ManaComponent(Component):
    """Component for entities that use magical energy.

    Mana is consumed when casting spells and regenerates over time.
    Some effects may also restore or drain mana.

    Attributes:
        current: Current mana points
        maximum: Maximum mana points
        regeneration_rate: Mana regenerated per second
        casting_modifier: Bonus/penalty to spell power
    """

    # NOTE: stdlib already ships a `ManaComponent` and registers it under the
    # type key "ManaComponent" via its content pack. The persistence
    # ComponentRegistry keys by get_type() and rejects a second registration of
    # the same type (ComponentConflictError). Because this tutorial's mana
    # component has extra fields (casting_modifier, time_since_cast, ...) that
    # stdlib's lacks, we give it a UNIQUE type identifier so both can coexist.
    component_type: ClassVar[str] = "MagicManaComponent"

    current: int = Field(default=100, ge=0, description="Current mana")
    maximum: int = Field(default=100, ge=1, description="Maximum mana")
    regeneration_rate: float = Field(
        default=2.0, ge=0.0, description="Mana per second"
    )
    casting_modifier: int = Field(
        default=0, description="Bonus to spell power"
    )

    # Tracking
    time_since_cast: float = Field(
        default=5.0, description="Seconds since last cast"
    )
    combat_regen_multiplier: float = Field(
        default=0.5, description="Regen rate multiplier in combat"
    )

    @property
    def percentage(self) -> float:
        """Get mana as a percentage (0-100)."""
        if self.maximum <= 0:
            return 0.0
        return (self.current / self.maximum) * 100.0

    @property
    def is_empty(self) -> bool:
        """Check if mana is depleted."""
        return self.current <= 0

    @property
    def is_full(self) -> bool:
        """Check if mana is at maximum."""
        return self.current >= self.maximum

    def can_cast(self, cost: int) -> bool:
        """Check if there is enough mana to cast a spell.

        Args:
            cost: The mana cost of the spell

        Returns:
            True if there is sufficient mana.
        """
        return self.current >= cost

    def consume(self, amount: int) -> bool:
        """Consume mana for a spell cast.

        Args:
            amount: Amount of mana to consume

        Returns:
            True if mana was successfully consumed.
        """
        if not self.can_cast(amount):
            return False
        self.current -= amount
        self.time_since_cast = 0.0
        return True

    def restore(self, amount: int) -> int:
        """Restore mana.

        Args:
            amount: Amount to restore

        Returns:
            Actual amount restored.
        """
        old_value = self.current
        self.current = min(self.maximum, self.current + amount)
        return self.current - old_value

    def drain(self, amount: int) -> int:
        """Drain mana (e.g., from enemy attack).

        Args:
            amount: Amount to drain

        Returns:
            Actual amount drained.
        """
        old_value = self.current
        self.current = max(0, self.current - amount)
        return old_value - self.current

Part 2: Spell Components

Define spell data structures:

# src/maid_magic_system/components/spells.py
"""Spell-related components."""

from enum import Enum
from typing import ClassVar
from uuid import UUID

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


class SpellSchool(str, Enum):
    """Schools of magic."""
    EVOCATION = "evocation"      # Damage spells
    RESTORATION = "restoration"  # Healing spells
    ABJURATION = "abjuration"    # Protective spells
    ENCHANTMENT = "enchantment"  # Buff/debuff spells
    CONJURATION = "conjuration"  # Summoning spells
    DIVINATION = "divination"    # Information spells


class SpellTarget(str, Enum):
    """Valid spell targets."""
    SELF = "self"
    SINGLE = "single"
    AREA = "area"
    ALL_ENEMIES = "all_enemies"
    ALL_ALLIES = "all_allies"


class Spell:
    """Definition of a spell.

    This is not a component but a data class for spell definitions.
    Spells are stored in SpellbookComponent.
    """

    def __init__(
        self,
        id: str,
        name: str,
        description: str,
        school: SpellSchool,
        mana_cost: int,
        cooldown: float = 0.0,
        cast_time: float = 0.0,
        target_type: SpellTarget = SpellTarget.SINGLE,
        base_power: int = 0,
        level_requirement: int = 1,
        effects: list[dict] | None = None,
    ):
        self.id = id
        self.name = name
        self.description = description
        self.school = school
        self.mana_cost = mana_cost
        self.cooldown = cooldown
        self.cast_time = cast_time
        self.target_type = target_type
        self.base_power = base_power
        self.level_requirement = level_requirement
        self.effects = effects or []


class SpellbookComponent(Component):
    """Component that stores learned spells.

    Attributes:
        known_spells: List of spell IDs the entity knows
        spell_cooldowns: Remaining cooldown for each spell
        active_spell: Currently channeling spell (if any)
    """

    component_type: ClassVar[str] = "SpellbookComponent"

    known_spells: list[str] = Field(
        default_factory=list,
        description="IDs of known spells"
    )
    spell_cooldowns: dict[str, float] = Field(
        default_factory=dict,
        description="Remaining cooldowns"
    )
    active_spell: str | None = Field(
        default=None,
        description="Spell being channeled"
    )
    channel_progress: float = Field(
        default=0.0,
        description="Channel time accumulated"
    )

    def knows_spell(self, spell_id: str) -> bool:
        """Check if entity knows a spell."""
        return spell_id in self.known_spells

    def learn_spell(self, spell_id: str) -> bool:
        """Learn a new spell.

        Returns:
            True if spell was learned, False if already known.
        """
        if spell_id in self.known_spells:
            return False
        self.known_spells.append(spell_id)
        self.notify_mutation()
        return True

    def forget_spell(self, spell_id: str) -> bool:
        """Forget a spell.

        Returns:
            True if spell was forgotten.
        """
        if spell_id not in self.known_spells:
            return False
        self.known_spells.remove(spell_id)
        self.notify_mutation()
        return True

    def is_on_cooldown(self, spell_id: str) -> bool:
        """Check if a spell is on cooldown."""
        return self.spell_cooldowns.get(spell_id, 0.0) > 0.0

    def get_cooldown(self, spell_id: str) -> float:
        """Get remaining cooldown for a spell."""
        return self.spell_cooldowns.get(spell_id, 0.0)

    def start_cooldown(self, spell_id: str, duration: float) -> None:
        """Start a spell cooldown."""
        self.spell_cooldowns[spell_id] = duration
        self.notify_mutation()

    def reduce_cooldowns(self, delta: float) -> None:
        """Reduce all cooldowns by time passed."""
        if not self.spell_cooldowns:
            return
        for spell_id in list(self.spell_cooldowns.keys()):
            self.spell_cooldowns[spell_id] -= delta
            if self.spell_cooldowns[spell_id] <= 0:
                del self.spell_cooldowns[spell_id]
        # Dict edits do not pass through __setattr__, so mark dirty explicitly.
        self.notify_mutation()


class SpellEffectComponent(Component):
    """Component for active spell effects on an entity.

    Tracks buffs, debuffs, and damage-over-time effects.
    """

    component_type: ClassVar[str] = "SpellEffectComponent"

    effects: list[dict] = Field(
        default_factory=list,
        description="Active spell effects"
    )

    def add_effect(
        self,
        effect_type: str,
        power: int,
        duration: float,
        source_id: UUID | None = None,
        spell_id: str = "",
    ) -> None:
        """Add a spell effect."""
        self.effects.append({
            "type": effect_type,
            "power": power,
            "duration": duration,
            "remaining": duration,
            "source_id": str(source_id) if source_id else None,
            "spell_id": spell_id,
        })
        # In-place list mutation bypasses dirty tracking; notify so the new
        # effect is persisted. (Reassigning self.effects would auto-track, but
        # append does not.)
        self.notify_mutation()

    def remove_expired(self) -> list[dict]:
        """Remove and return expired effects."""
        expired = [e for e in self.effects if e["remaining"] <= 0]
        self.effects = [e for e in self.effects if e["remaining"] > 0]
        return expired

    def tick(self, delta: float) -> None:
        """Reduce all effect durations."""
        if not self.effects:
            return
        for effect in self.effects:
            effect["remaining"] -= delta
        # Mutating dicts inside the list does not go through __setattr__, so
        # mark the component dirty explicitly.
        self.notify_mutation()

    def get_effects_by_type(self, effect_type: str) -> list[dict]:
        """Get all effects of a specific type."""
        return [e for e in self.effects if e["type"] == effect_type]

    def clear_effects(self) -> None:
        """Remove all effects."""
        self.effects.clear()
        self.notify_mutation()

Part 3: Spell Registry

Create a spell registry with predefined spells:

# src/maid_magic_system/data/spells.py
"""Predefined spell definitions."""

from ..components.spells import Spell, SpellSchool, SpellTarget


# Evocation (Damage) Spells
FIREBALL = Spell(
    id="fireball",
    name="Fireball",
    description="Hurls a ball of fire at the target",
    school=SpellSchool.EVOCATION,
    mana_cost=25,
    cooldown=3.0,
    target_type=SpellTarget.SINGLE,
    base_power=30,
    level_requirement=1,
    effects=[
        {"type": "damage", "damage_type": "fire"},
    ],
)

LIGHTNING_BOLT = Spell(
    id="lightning_bolt",
    name="Lightning Bolt",
    description="Strikes the target with lightning",
    school=SpellSchool.EVOCATION,
    mana_cost=35,
    cooldown=5.0,
    target_type=SpellTarget.SINGLE,
    base_power=45,
    level_requirement=3,
    effects=[
        {"type": "damage", "damage_type": "lightning"},
    ],
)

ICE_STORM = Spell(
    id="ice_storm",
    name="Ice Storm",
    description="Unleashes a storm of ice on all enemies",
    school=SpellSchool.EVOCATION,
    mana_cost=50,
    cooldown=10.0,
    target_type=SpellTarget.ALL_ENEMIES,
    base_power=20,
    level_requirement=5,
    effects=[
        {"type": "damage", "damage_type": "cold"},
        {"type": "slow", "power": 30, "duration": 5.0},
    ],
)

# Restoration (Healing) Spells
HEAL = Spell(
    id="heal",
    name="Heal",
    description="Restores health to the target",
    school=SpellSchool.RESTORATION,
    mana_cost=20,
    cooldown=2.0,
    target_type=SpellTarget.SINGLE,
    base_power=25,
    level_requirement=1,
    effects=[
        {"type": "heal"},
    ],
)

GREATER_HEAL = Spell(
    id="greater_heal",
    name="Greater Heal",
    description="Powerfully restores health",
    school=SpellSchool.RESTORATION,
    mana_cost=45,
    cooldown=5.0,
    cast_time=2.0,  # Channeled spell
    target_type=SpellTarget.SINGLE,
    base_power=60,
    level_requirement=4,
    effects=[
        {"type": "heal"},
    ],
)

REGENERATION = Spell(
    id="regeneration",
    name="Regeneration",
    description="Gradually restores health over time",
    school=SpellSchool.RESTORATION,
    mana_cost=30,
    cooldown=15.0,
    target_type=SpellTarget.SINGLE,
    base_power=5,
    level_requirement=2,
    effects=[
        {"type": "heal_over_time", "duration": 10.0, "tick_rate": 1.0},
    ],
)

# Abjuration (Protection) Spells
SHIELD = Spell(
    id="shield",
    name="Shield",
    description="Creates a protective barrier",
    school=SpellSchool.ABJURATION,
    mana_cost=30,
    cooldown=20.0,
    target_type=SpellTarget.SELF,
    base_power=50,
    level_requirement=2,
    effects=[
        {"type": "absorb", "duration": 15.0},
    ],
)

# Enchantment (Buff/Debuff) Spells
STRENGTH = Spell(
    id="strength",
    name="Strength",
    description="Increases attack power",
    school=SpellSchool.ENCHANTMENT,
    mana_cost=25,
    cooldown=30.0,
    target_type=SpellTarget.SINGLE,
    base_power=10,
    level_requirement=2,
    effects=[
        {"type": "buff", "stat": "attack_power", "duration": 60.0},
    ],
)

WEAKEN = Spell(
    id="weaken",
    name="Weaken",
    description="Reduces target's defense",
    school=SpellSchool.ENCHANTMENT,
    mana_cost=20,
    cooldown=15.0,
    target_type=SpellTarget.SINGLE,
    base_power=5,
    level_requirement=1,
    effects=[
        {"type": "debuff", "stat": "defense", "duration": 30.0},
    ],
)


# Spell Registry
SPELL_REGISTRY: dict[str, Spell] = {
    spell.id: spell for spell in [
        FIREBALL,
        LIGHTNING_BOLT,
        ICE_STORM,
        HEAL,
        GREATER_HEAL,
        REGENERATION,
        SHIELD,
        STRENGTH,
        WEAKEN,
    ]
}


def get_spell(spell_id: str) -> Spell | None:
    """Get a spell by ID."""
    return SPELL_REGISTRY.get(spell_id)


def get_all_spells() -> list[Spell]:
    """Get all registered spells."""
    return list(SPELL_REGISTRY.values())


def get_spells_by_school(school: SpellSchool) -> list[Spell]:
    """Get all spells of a specific school."""
    return [s for s in SPELL_REGISTRY.values() if s.school == school]

Part 4: Spell Events

The magic system communicates through events. Define them in the events package so that imports like from ..events import SpellCastEvent resolve. All three subclass the engine's Event dataclass. Its bookkeeping fields (event_type, timestamp, cancelled) are declared init=False, so subclasses are free to add their own required fields without hitting the "non-default argument follows default argument" error.

# src/maid_magic_system/events/magic_events.py
"""Events emitted and consumed by the magic system."""

from __future__ import annotations

from dataclasses import dataclass
from uuid import UUID

from maid_engine.core.events import Event


@dataclass
class SpellCastEvent(Event):
    """Emitted when an entity requests to cast a spell.

    The MagicSystem subscribes to this event, validates requirements, and applies
    the spell's effects.
    """

    caster_id: UUID
    spell_id: str
    target_id: UUID | None = None


@dataclass
class SpellEffectEvent(Event):
    """Emitted after a spell successfully applies an effect to a target."""

    caster_id: UUID
    target_id: UUID
    spell_id: str
    effect_type: str


@dataclass
class SpellFailedEvent(Event):
    """Emitted when a spell cannot be cast (unknown spell, no mana, on cooldown, ...)."""

    caster_id: UUID
    spell_id: str
    reason: str

Re-export the events from the package __init__.py so the shorter from ..events import SpellCastEvent import used throughout the pack works:

# src/maid_magic_system/events/__init__.py
"""Magic system events."""

from .magic_events import SpellCastEvent, SpellEffectEvent, SpellFailedEvent

__all__ = ["SpellCastEvent", "SpellEffectEvent", "SpellFailedEvent"]

Part 5: Magic System

Create the main magic system:

# src/maid_magic_system/systems/magic_system.py
"""Magic system for spell casting and effects."""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar
from uuid import UUID

from maid_engine.core.ecs import System
from maid_stdlib.components import DescriptionComponent, HealthComponent, PositionComponent

from ..components.mana import ManaComponent
from ..components.spells import SpellbookComponent, SpellEffectComponent, SpellTarget
from ..data.spells import get_spell
from ..events import SpellCastEvent, SpellEffectEvent, SpellFailedEvent

if TYPE_CHECKING:
    from maid_engine.core.ecs import Entity


class MagicSystem(System):
    """System that handles spell casting and magic effects.

    Responsibilities:
    - Process spell cast requests
    - Validate mana and cooldowns
    - Calculate spell effects
    - Apply damage, healing, buffs, debuffs
    - Process active spell effects each tick
    """

    priority: ClassVar[int] = 55  # Run after combat

    async def startup(self) -> None:
        """Subscribe to spell events."""
        # Store the handler id so shutdown() can unsubscribe. The event bus does
        # not auto-remove a system's handlers on shutdown/reload.
        self._cast_handler = self.events.subscribe(
            SpellCastEvent, self._handle_spell_cast
        )

    async def shutdown(self) -> None:
        """Unsubscribe from spell events."""
        self.events.unsubscribe(self._cast_handler)

    async def update(self, delta: float) -> None:
        """Process active spell effects."""
        # Update cooldowns
        for entity in self.entities.with_components(SpellbookComponent):
            spellbook = entity.get(SpellbookComponent)
            spellbook.reduce_cooldowns(delta)

        # Process active effects
        for entity in self.entities.with_components(SpellEffectComponent):
            effects = entity.get(SpellEffectComponent)
            await self._process_effects(entity, effects, delta)

    async def _handle_spell_cast(self, event: SpellCastEvent) -> None:
        """Handle a spell cast request."""
        caster = self.entities.get(event.caster_id)
        if not caster:
            return

        # Get spell definition
        spell = get_spell(event.spell_id)
        if not spell:
            await self.events.emit(SpellFailedEvent(
                caster_id=event.caster_id,
                spell_id=event.spell_id,
                reason="Unknown spell",
            ))
            return

        # Check requirements
        fail_reason = self._check_cast_requirements(caster, spell)
        if fail_reason:
            await self.events.emit(SpellFailedEvent(
                caster_id=event.caster_id,
                spell_id=event.spell_id,
                reason=fail_reason,
            ))
            return

        # Get targets
        targets = self._get_targets(caster, event.target_id, spell.target_type)
        if not targets and spell.target_type != SpellTarget.SELF:
            await self.events.emit(SpellFailedEvent(
                caster_id=event.caster_id,
                spell_id=event.spell_id,
                reason="No valid targets",
            ))
            return

        # Consume mana
        mana = caster.get(ManaComponent)
        mana.consume(spell.mana_cost)

        # Start cooldown
        spellbook = caster.get(SpellbookComponent)
        if spell.cooldown > 0:
            spellbook.start_cooldown(spell.id, spell.cooldown)

        # Calculate power
        power = spell.base_power + mana.casting_modifier

        # Apply effects to all targets
        for target in targets:
            await self._apply_spell_effects(caster, target, spell, power)

    def _check_cast_requirements(self, caster: Entity, spell) -> str | None:
        """Check if caster meets requirements to cast a spell.

        Returns:
            Error message if requirements not met, None otherwise.
        """
        # Check mana
        mana = caster.try_get(ManaComponent)
        if not mana:
            return "You have no magical ability"
        if not mana.can_cast(spell.mana_cost):
            return "Not enough mana"

        # Check spellbook
        spellbook = caster.try_get(SpellbookComponent)
        if not spellbook:
            return "You have no spellbook"
        if not spellbook.knows_spell(spell.id):
            return "You don't know that spell"

        # Check cooldown
        if spellbook.is_on_cooldown(spell.id):
            remaining = spellbook.get_cooldown(spell.id)
            return f"Spell on cooldown ({remaining:.1f}s)"

        return None

    def _get_targets(
        self,
        caster: Entity,
        target_id: UUID | None,
        target_type: SpellTarget
    ) -> list[Entity]:
        """Get valid targets for a spell."""
        if target_type == SpellTarget.SELF:
            return [caster]

        caster_pos = caster.try_get(PositionComponent)
        if not caster_pos:
            return []

        if target_type == SpellTarget.SINGLE:
            if target_id:
                target = self.entities.get(target_id)
                if target:
                    target_pos = target.try_get(PositionComponent)
                    if target_pos and target_pos.room_id == caster_pos.room_id:
                        return [target]
            return []

        # Area/group targeting
        targets = []
        for entity in self.entities.with_components(PositionComponent):
            if entity.id == caster.id:
                continue

            pos = entity.get(PositionComponent)
            if pos.room_id != caster_pos.room_id:
                continue

            if target_type == SpellTarget.ALL_ENEMIES:
                if entity.has_tag("hostile"):
                    targets.append(entity)
            elif target_type == SpellTarget.ALL_ALLIES:
                if entity.has_tag("player") or entity.has_tag("ally"):
                    targets.append(entity)
            elif target_type == SpellTarget.AREA:
                targets.append(entity)

        return targets

    async def _apply_spell_effects(
        self,
        caster: Entity,
        target: Entity,
        spell,
        power: int
    ) -> None:
        """Apply spell effects to a target."""
        for effect in spell.effects:
            effect_type = effect["type"]

            if effect_type == "damage":
                await self._apply_damage(caster, target, power, effect)

            elif effect_type == "heal":
                await self._apply_healing(caster, target, power)

            elif effect_type == "heal_over_time":
                self._apply_hot(caster, target, power, effect, spell.id)

            elif effect_type in ("buff", "debuff"):
                self._apply_stat_modifier(caster, target, power, effect, spell.id)

            elif effect_type == "absorb":
                self._apply_shield(caster, target, power, effect, spell.id)

        # Emit spell effect event
        await self.events.emit(SpellEffectEvent(
            caster_id=caster.id,
            target_id=target.id,
            spell_id=spell.id,
            effect_type=spell.effects[0]["type"] if spell.effects else "none",
        ))

    async def _apply_damage(
        self,
        caster: Entity,
        target: Entity,
        power: int,
        effect: dict
    ) -> None:
        """Apply magical damage to a target."""
        health = target.try_get(HealthComponent)
        if not health:
            return

        damage_type = effect.get("damage_type", "magical")
        # Could apply resistances here
        actual_damage = health.damage(power)

        # Emit the standard library damage event so combat logs and other
        # systems observe magical damage. damage_type is a required field.
        from maid_stdlib.events import DamageDealtEvent, EntityDeathEvent
        await self.events.emit(DamageDealtEvent(
            source_id=caster.id,
            target_id=target.id,
            damage=actual_damage,
            damage_type=damage_type,
        ))

        # If the spell was lethal, drive the STANDARD death pipeline with
        # attribution. maid_stdlib's HealthCheckSystem would otherwise emit an
        # EntityDeathEvent with killer_id=None (it cannot know the caster).
        # Tagging "dead" first suppresses that un-attributed duplicate, so we
        # emit the attributed EntityDeathEvent here to credit the caster.
        if not health.is_alive and not target.has_tag("dead"):
            target.add_tag("dead")
            await self.events.emit(EntityDeathEvent(
                entity_id=target.id,
                killer_id=caster.id,
            ))

    async def _apply_healing(
        self,
        caster: Entity,
        target: Entity,
        power: int
    ) -> None:
        """Apply healing to a target."""
        health = target.try_get(HealthComponent)
        if not health:
            return

        actual_healing = health.heal(power)

    def _apply_hot(
        self,
        caster: Entity,
        target: Entity,
        power: int,
        effect: dict,
        spell_id: str
    ) -> None:
        """Apply heal-over-time effect."""
        effects = target.try_get(SpellEffectComponent)
        if not effects:
            target.add(SpellEffectComponent())
            effects = target.get(SpellEffectComponent)

        effects.add_effect(
            effect_type="heal_over_time",
            power=power,
            duration=effect.get("duration", 10.0),
            source_id=caster.id,
            spell_id=spell_id,
        )

    def _apply_stat_modifier(
        self,
        caster: Entity,
        target: Entity,
        power: int,
        effect: dict,
        spell_id: str
    ) -> None:
        """Apply buff or debuff."""
        effects = target.try_get(SpellEffectComponent)
        if not effects:
            target.add(SpellEffectComponent())
            effects = target.get(SpellEffectComponent)

        effect_power = power if effect["type"] == "buff" else -power

        effects.add_effect(
            effect_type=f"stat_{effect['stat']}",
            power=effect_power,
            duration=effect.get("duration", 30.0),
            source_id=caster.id,
            spell_id=spell_id,
        )

    def _apply_shield(
        self,
        caster: Entity,
        target: Entity,
        power: int,
        effect: dict,
        spell_id: str
    ) -> None:
        """Apply damage absorption shield."""
        effects = target.try_get(SpellEffectComponent)
        if not effects:
            target.add(SpellEffectComponent())
            effects = target.get(SpellEffectComponent)

        effects.add_effect(
            effect_type="absorb",
            power=power,
            duration=effect.get("duration", 15.0),
            source_id=caster.id,
            spell_id=spell_id,
        )

    async def _process_effects(
        self,
        entity: Entity,
        effects: SpellEffectComponent,
        delta: float
    ) -> None:
        """Process active spell effects on an entity."""
        # Process heal-over-time
        for hot in effects.get_effects_by_type("heal_over_time"):
            # Apply healing tick. power is per-second, so multiply by delta.
            # A naive int(power * delta) truncates every tick and silently
            # loses the remainder (e.g. 5/s at 0.25s ticks -> int(1.25)=1,
            # dropping 0.25 each tick). Carry the fractional part on the effect
            # dict so slow HoTs heal their full amount over time, exactly like
            # the mana-regen system accumulates fractional mana.
            health = entity.try_get(HealthComponent)
            if health:
                carried = hot.get("_carry", 0.0) + hot["power"] * delta
                whole = int(carried)
                if whole > 0:
                    health.heal(whole)
                hot["_carry"] = carried - whole

        # Tick durations
        effects.tick(delta)

        # Remove expired effects
        expired = effects.remove_expired()
        # Could emit events for expired effects

Scope of this tutorial's spell system. To keep the example focused, only a subset of the declared spell data is actually enforced at runtime. Be honest with yourself about what is wired up before you build on it:

Mechanic Status
Mana cost, cooldown, knows_spell gate Enforced (_check_cast_requirements)
SELF / SINGLE / AREA / ALL_ENEMIES / ALL_ALLIES targeting Enforced (_get_targets)
Direct damage, heal Enforced (_apply_damage / _apply_healing)
heal_over_time Enforced (_process_effects, with fractional carry)
buff / debuff stat modifiers Stored only. _apply_stat_modifier records a stat_* effect, but _process_effects never reads it, so no stat actually changes.
absorb (Shield) Stored only. The effect is recorded, but _apply_damage does not subtract it, so it absorbs nothing.
slow (e.g. on ICE_STORM) Ignored. _apply_spell_effects has no slow branch, so the effect dict is silently dropped.
cast_time (channeled spells) Not enforced. Every spell resolves instantly.
level_requirement Not checked. _check_cast_requirements does not inspect caster level.

Wiring these up is left as an exercise: read stat_*/absorb effects in _process_effects (and in _apply_damage for shields), add a slow branch that stores a stat_speed debuff, gate casting on level in _check_cast_requirements, and defer effect application by cast_time using a per-caster channel timer. Until then, treat those fields as design intent, not working behavior.

Part 6: Mana Regeneration System

Mana regenerates over time. Because regeneration_rate is fractional (2.0 mana/second by default) and a tick is only a fraction of a second, truncating to whole mana each tick would lose the remainder. This system accumulates fractional mana between ticks and only ever restores whole points:

# src/maid_magic_system/systems/mana_regen_system.py
"""Regenerates mana over time."""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar
from uuid import UUID

from maid_engine.core.ecs import System

from ..components.mana import ManaComponent

if TYPE_CHECKING:
    from maid_engine.core.world import World


class ManaRegenSystem(System):
    """Restores mana each tick based on each entity's regeneration_rate."""

    priority: ClassVar[int] = 40  # Run before the magic system

    def __init__(self, world: World) -> None:
        super().__init__(world)
        # Carry fractional mana between ticks so slow regen isn't lost to truncation.
        self._accumulated: dict[UUID, float] = {}

    async def update(self, delta: float) -> None:
        live_ids: set[UUID] = set()
        for entity in self.entities.with_components(ManaComponent):
            live_ids.add(entity.id)
            mana = entity.get(ManaComponent)
            if mana.is_full:
                self._accumulated.pop(entity.id, None)
                continue

            gained = self._accumulated.get(entity.id, 0.0)
            gained += mana.regeneration_rate * delta
            whole = int(gained)
            if whole > 0:
                mana.restore(whole)
            self._accumulated[entity.id] = gained - whole

        # Prune accumulators for entities that no longer exist (destroyed) or
        # that lost their ManaComponent this tick. Without this the dict grows
        # without bound as mobs/players spawn and are removed.
        stale_ids = self._accumulated.keys() - live_ids
        for stale_id in stale_ids:
            del self._accumulated[stale_id]

    async def shutdown(self) -> None:
        self._accumulated.clear()

Part 7: Magic Commands

Create the cast command:

# src/maid_magic_system/commands/magic_commands.py
"""Commands for the magic system."""

from maid_engine.commands.registry import AccessLevel, CommandContext, LayeredCommandRegistry
from maid_stdlib.components import DescriptionComponent, PositionComponent

from ..components.mana import ManaComponent
from ..components.spells import SpellbookComponent
from ..data.spells import get_spell, get_all_spells
from ..events import SpellCastEvent


async def cast_command(ctx: CommandContext) -> bool:
    """Cast a spell.

    Usage:
        cast <spell> [target]
        cast fireball goblin
        cast heal self
    """
    if not ctx.args:
        await ctx.session.send("Cast what spell?")
        await ctx.session.send("Usage: cast <spell> [target]")
        return True

    spell_name = ctx.args[0].lower()
    target_name = ctx.args[1].lower() if len(ctx.args) > 1 else None

    # Get player
    player = ctx.world.entities.get(ctx.player_id)
    if not player:
        return False

    # Check player has magic ability
    mana = player.try_get(ManaComponent)
    if not mana:
        await ctx.session.send("You have no magical ability.")
        return True

    spellbook = player.try_get(SpellbookComponent)
    if not spellbook:
        await ctx.session.send("You don't have a spellbook.")
        return True

    # Find spell
    spell = get_spell(spell_name)
    if not spell:
        # Try to find by partial name
        for s in get_all_spells():
            if spell_name in s.name.lower():
                spell = s
                break

    if not spell:
        await ctx.session.send(f"Unknown spell: {spell_name}")
        return True

    # Check requirements
    if not spellbook.knows_spell(spell.id):
        await ctx.session.send(f"You don't know {spell.name}.")
        return True

    if not mana.can_cast(spell.mana_cost):
        await ctx.session.send(
            f"Not enough mana. Need {spell.mana_cost}, have {mana.current}."
        )
        return True

    if spellbook.is_on_cooldown(spell.id):
        remaining = spellbook.get_cooldown(spell.id)
        await ctx.session.send(f"{spell.name} is on cooldown ({remaining:.1f}s).")
        return True

    # Find target
    target_id = None
    if target_name == "self":
        # "self" must resolve to the caster's own id. Leaving it as None makes
        # SINGLE-target spells (heal, greater_heal, strength, ...) fail with
        # "No valid targets", because _get_targets() only returns [caster] for
        # spells whose target_type is SpellTarget.SELF. Setting the id here lets
        # "cast heal self" work for ordinary single-target spells too.
        target_id = ctx.player_id
    elif target_name:
        target = await find_target(ctx, target_name)
        if not target:
            await ctx.session.send(f"You don't see '{target_name}' here.")
            return True
        target_id = target.id

    # Cast the spell
    await ctx.session.send(f"You cast {spell.name}!")

    await ctx.world.events.emit(SpellCastEvent(
        caster_id=ctx.player_id,
        spell_id=spell.id,
        target_id=target_id,
    ))

    return True


async def learn_command(ctx: CommandContext) -> bool:
    """Learn a spell, adding it to your spellbook.

    Usage:
        learn <spell>
        learn fireball

    Note:
        For simplicity this tutorial lets a player learn any defined spell. A
        production game would gate learning behind trainers, level requirements,
        or quest rewards.
    """
    if not ctx.args:
        await ctx.session.send("Learn what spell?")
        await ctx.session.send("Usage: learn <spell>")
        return True

    spell_name = ctx.args[0].lower()

    player = ctx.world.entities.get(ctx.player_id)
    if not player:
        return False

    spellbook = player.try_get(SpellbookComponent)
    if not spellbook:
        await ctx.session.send("You don't have a spellbook.")
        return True

    # Find spell by exact id first, then partial name match (same as `cast`).
    spell = get_spell(spell_name)
    if not spell:
        for s in get_all_spells():
            if spell_name in s.name.lower():
                spell = s
                break

    if not spell:
        await ctx.session.send(f"Unknown spell: {spell_name}")
        return True

    # learn_spell() returns False if already known and calls notify_mutation()
    # internally, so the newly learned spell is tracked for persistence.
    if not spellbook.learn_spell(spell.id):
        await ctx.session.send(f"You already know {spell.name}.")
        return True

    await ctx.session.send(f"You have learned {spell.name}!")
    return True


async def spells_command(ctx: CommandContext) -> bool:
    """List known spells.

    Usage:
        spells
    """
    player = ctx.world.entities.get(ctx.player_id)
    if not player:
        return False

    spellbook = player.try_get(SpellbookComponent)
    if not spellbook:
        await ctx.session.send("You don't have a spellbook.")
        return True

    mana = player.try_get(ManaComponent)

    if not spellbook.known_spells:
        await ctx.session.send("You don't know any spells.")
        return True

    await ctx.session.send("Known Spells:")
    await ctx.session.send("-" * 40)

    for spell_id in spellbook.known_spells:
        spell = get_spell(spell_id)
        if not spell:
            continue

        # Build spell line
        status = ""
        if spellbook.is_on_cooldown(spell_id):
            cd = spellbook.get_cooldown(spell_id)
            status = f" [CD: {cd:.1f}s]"
        elif mana and not mana.can_cast(spell.mana_cost):
            status = " [No mana]"

        await ctx.session.send(
            f"  {spell.name} ({spell.mana_cost} mana) - {spell.description}{status}"
        )

    if mana:
        await ctx.session.send("-" * 40)
        await ctx.session.send(f"Mana: {mana.current}/{mana.maximum}")

    return True


async def find_target(ctx: CommandContext, name: str):
    """Find a target by name in the player's room."""
    player = ctx.world.entities.get(ctx.player_id)
    if not player:
        return None

    player_pos = player.try_get(PositionComponent)
    if not player_pos:
        return None

    for entity in ctx.world.entities.with_components(PositionComponent, DescriptionComponent):
        pos = entity.get(PositionComponent)
        if pos.room_id != player_pos.room_id:
            continue

        desc = entity.get(DescriptionComponent)
        if name in str(desc.name).lower():
            return entity

    return None


def register_commands(registry: LayeredCommandRegistry, pack_name: str) -> None:
    """Register magic commands."""
    registry.register(
        name="cast",
        handler=cast_command,
        pack_name=pack_name,
        aliases=["c"],
        category="magic",
        description="Cast a spell",
        usage="cast <spell> [target]",
        access_level=AccessLevel.PLAYER,
    )

    registry.register(
        name="spells",
        handler=spells_command,
        pack_name=pack_name,
        aliases=["spellbook", "sb"],
        category="magic",
        description="List known spells",
        usage="spells",
        access_level=AccessLevel.PLAYER,
    )

    registry.register(
        name="learn",
        handler=learn_command,
        pack_name=pack_name,
        category="magic",
        description="Learn a spell",
        usage="learn <spell>",
        access_level=AccessLevel.PLAYER,
    )

Part 8: Content Pack

Finally, wire everything together in a ContentPack so the engine can load it. Subclassing BaseContentPack provides no-op defaults for every hook, so you only override what the magic pack actually needs: its manifest, its dependency on stdlib, its systems, its events, and its commands.

# src/maid_magic_system/pack.py
"""Magic system content pack."""

from __future__ import annotations

from typing import TYPE_CHECKING

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

from .commands.magic_commands import register_commands as register_magic_commands
from .components.mana import ManaComponent
from .components.spells import SpellbookComponent, SpellEffectComponent
from .events import SpellCastEvent, SpellEffectEvent, SpellFailedEvent
from .systems.magic_system import MagicSystem
from .systems.mana_regen_system import ManaRegenSystem

if TYPE_CHECKING:
    from maid_engine.commands.registry import LayeredCommandRegistry
    from maid_engine.core.ecs import System
    from maid_engine.core.events import Event
    from maid_engine.core.world import World
    from maid_engine.persistence.registry import ComponentRegistry


class MagicContentPack(BaseContentPack):
    """Content pack that adds mana-based spellcasting."""

    @property
    def manifest(self) -> ContentPackManifest:
        return ContentPackManifest(
            name="magic-system",
            version="1.0.0",
            display_name="Magic System",
            description="Mana-based spellcasting with spellbooks, cooldowns, and effects.",
            dependencies={"stdlib": ">=0.1.0"},
        )

    def get_dependencies(self) -> list[str]:
        # Must match the depended-on pack's manifest.name ("stdlib"), not its
        # distribution name ("maid-stdlib").
        return ["stdlib"]

    def get_systems(self, world: World) -> list[System]:
        # Mana regen runs first (priority 40), then the magic system (priority 55).
        return [ManaRegenSystem(world), MagicSystem(world)]

    def get_events(self) -> list[type[Event]]:
        return [SpellCastEvent, SpellEffectEvent, SpellFailedEvent]

    def register_component_types(self, registry: ComponentRegistry) -> None:
        # Register components so they can be serialized and restored by the
        # persistence layer. Without this, saved entities can't rebuild them.
        registry.register(ManaComponent, pack_name=self.manifest.name)
        registry.register(SpellbookComponent, pack_name=self.manifest.name)
        registry.register(SpellEffectComponent, pack_name=self.manifest.name)

    def register_commands(self, registry: LayeredCommandRegistry) -> None:
        register_magic_commands(registry, self.manifest.name)

Load the pack alongside the standard library it depends on when starting the engine. Settings() refuses to start with an insecure admin secret, so either export a real 32+ character MAID_ADMIN_SECRET_KEY or set MAID_DEBUG=true for local development:

from maid_engine.config.settings import Settings
from maid_engine.core.engine import GameEngine
from maid_stdlib.pack import StdlibContentPack

from maid_magic_system.pack import MagicContentPack

engine = GameEngine(Settings())              # MAID_DEBUG=true for local dev
engine.load_content_pack(StdlibContentPack())   # dependency, load first
engine.load_content_pack(MagicContentPack())
await engine.start()

Once loaded, any entity that has a ManaComponent and a SpellbookComponent can use the cast and spells commands.

Summary

This tutorial covered:

  1. Mana Component: Resource management for spell casting
  2. Spell Components: Spell definitions and spellbooks
  3. Spell Registry: Predefined spell data
  4. Spell Events: SpellCastEvent, SpellEffectEvent, and SpellFailedEvent
  5. Magic System: Spell casting, targeting, and effect processing
  6. Mana Regeneration System: Fractional-safe mana regeneration each tick
  7. Commands: Player interface for casting spells
  8. Content Pack: Registering components, systems, events, and commands so the engine can load it

Integration with Combat

The magic system integrates with the combat system by:

  • Emitting DamageDealtEvent for damage spells
  • Using the same targeting system
  • Sharing components like HealthComponent

Next Steps

  • Add more spell effects (stun, teleport, summon)
  • Implement spell combos
  • Create a learning/leveling system for spells
  • Add equipment that boosts magic

Continue to the Complete Game Tutorial to see how all systems work together.