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,learncommands - 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_spellgateEnforced ( _check_cast_requirements)SELF/SINGLE/AREA/ALL_ENEMIES/ALL_ALLIEStargetingEnforced ( _get_targets)Direct damage,healEnforced ( _apply_damage/_apply_healing)heal_over_timeEnforced ( _process_effects, with fractional carry)buff/debuffstat modifiersStored only. _apply_stat_modifierrecords astat_*effect, but_process_effectsnever reads it, so no stat actually changes.absorb(Shield)Stored only. The effect is recorded, but _apply_damagedoes not subtract it, so it absorbs nothing.slow(e.g. onICE_STORM)Ignored. _apply_spell_effectshas noslowbranch, so the effect dict is silently dropped.cast_time(channeled spells)Not enforced. Every spell resolves instantly. level_requirementNot checked. _check_cast_requirementsdoes not inspect caster level.Wiring these up is left as an exercise: read
stat_*/absorbeffects in_process_effects(and in_apply_damagefor shields), add aslowbranch that stores astat_speeddebuff, gate casting on level in_check_cast_requirements, and defer effect application bycast_timeusing 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:
- Mana Component: Resource management for spell casting
- Spell Components: Spell definitions and spellbooks
- Spell Registry: Predefined spell data
- Spell Events:
SpellCastEvent,SpellEffectEvent, andSpellFailedEvent - Magic System: Spell casting, targeting, and effect processing
- Mana Regeneration System: Fractional-safe mana regeneration each tick
- Commands: Player interface for casting spells
- 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
DamageDealtEventfor 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.