Skip to content

Creating Custom Events

Custom events let you define domain-specific messages for your content pack. This guide covers how to create, emit, and handle custom events.

Defining Custom Events

Basic Event

Events are dataclasses that inherit from Event:

from dataclasses import dataclass
from uuid import UUID
from maid_engine.core.events import Event

@dataclass
class SpellCastEvent(Event):
    """Emitted when a spell is cast."""
    caster_id: UUID
    spell_name: str
    target_id: UUID | None = None

With Complex Types

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from uuid import UUID
from maid_engine.core.events import Event

class DamageType(Enum):
    PHYSICAL = "physical"
    MAGICAL = "magical"
    FIRE = "fire"
    ICE = "ice"

@dataclass
class CombatDamageEvent(Event):
    """Emitted when combat damage is dealt."""
    attacker_id: UUID
    target_id: UUID
    base_damage: int
    damage_type: DamageType
    is_critical: bool = False
    modifiers: list[str] = field(default_factory=list)

With Computed Properties

@dataclass
class ItemTransferEvent(Event):
    """Emitted when an item is transferred between entities."""
    item_id: UUID
    from_entity_id: UUID | None
    to_entity_id: UUID | None
    quantity: int = 1

    @property
    def is_pickup(self) -> bool:
        """Item picked up from the ground."""
        return self.from_entity_id is None

    @property
    def is_drop(self) -> bool:
        """Item dropped to the ground."""
        return self.to_entity_id is None

    @property
    def is_trade(self) -> bool:
        """Item traded between entities."""
        return self.from_entity_id is not None and self.to_entity_id is not None

Event Inheritance

Base Events

Create base events for families of related events:

@dataclass
class CombatEvent(Event):
    """Base class for all combat-related events."""
    room_id: UUID
    tick: int

@dataclass
class AttackEvent(CombatEvent):
    """Attack initiated."""
    attacker_id: UUID
    target_id: UUID
    weapon_id: UUID | None = None

@dataclass
class DamageDealtEvent(CombatEvent):
    """Damage was dealt."""
    source_id: UUID
    target_id: UUID
    damage: int

@dataclass
class CombatEndedEvent(CombatEvent):
    """Combat ended."""
    winner_id: UUID | None = None
    loser_ids: list[UUID] = field(default_factory=list)

Subscribing to Base Events

Note: MAID's EventBus matches exact event types, not inheritance:

# This only matches AttackEvent, not CombatEvent handlers
await bus.emit(AttackEvent(...))

# To handle all combat events, subscribe to each type:
bus.subscribe(AttackEvent, handle_combat)
bus.subscribe(DamageDealtEvent, handle_combat)
bus.subscribe(CombatEndedEvent, handle_combat)

Registering Custom Events

Via Content Pack

Register events in your content pack:

class MyCombatPack:
    def get_events(self) -> list[type[Event]]:
        """Return custom event types."""
        return [
            AttackEvent,
            DamageDealtEvent,
            CombatEndedEvent,
            SpellCastEvent,
        ]

This helps with documentation and discovery - the engine knows what events your pack defines.

Emitting Custom Events

Basic Emission

from my_pack.events import SpellCastEvent

# In a system or command handler
async def cast_spell(caster_id: UUID, spell: str, target_id: UUID | None) -> None:
    # ... spell logic ...

    # Emit event
    await events.emit(SpellCastEvent(
        caster_id=caster_id,
        spell_name=spell,
        target_id=target_id,
    ))

Conditional Emission

async def process_attack(attacker: Entity, target: Entity) -> None:
    damage = calculate_damage(attacker, target)

    # Emit damage event
    await events.emit(DamageDealtEvent(
        room_id=get_room_id(attacker),
        tick=current_tick,
        source_id=attacker.id,
        target_id=target.id,
        damage=damage,
    ))

    # Conditionally emit death event
    health = target.get(HealthComponent)
    if health.current <= 0:
        await events.emit(EntityDiedEvent(
            entity_id=target.id,
            killer_id=attacker.id,
        ))

Chained Events

async def complete_quest(player_id: UUID, quest_id: str) -> None:
    # Emit quest completion
    await events.emit(QuestCompletedEvent(
        player_id=player_id,
        quest_id=quest_id,
    ))

    # Check for achievements (handlers will emit achievement events)
    # Check for level up (handlers will emit level up events)
    # The event chain handles cascading effects

Event Design Patterns

Request-Response Pattern

Use events for requests that might be modified or cancelled:

@dataclass
class HealRequestEvent(Event):
    """Request to heal an entity. Can be modified or cancelled."""
    target_id: UUID
    amount: int
    source_id: UUID | None = None

    # Mutable - handlers can modify
    final_amount: int = 0

# High priority handler - apply healing modifiers
async def apply_healing_bonus(event: HealRequestEvent) -> None:
    target = world.get_entity(event.target_id)
    bonus = target.try_get(HealingBonusComponent)
    if bonus:
        event.final_amount = int(event.amount * bonus.multiplier)
    else:
        event.final_amount = event.amount

# Normal priority handler - apply the healing
async def apply_healing(event: HealRequestEvent) -> None:
    if event.cancelled:
        return

    target = world.get_entity(event.target_id)
    health = target.get(HealthComponent)
    health.current = min(health.maximum, health.current + event.final_amount)

Notification Pattern

Events that inform but don't require action:

@dataclass
class PlayerLeveledUpEvent(Event):
    """Notification that a player leveled up."""
    player_id: UUID
    old_level: int
    new_level: int

# Handlers can react but not modify the fact
async def announce_level_up(event: PlayerLeveledUpEvent) -> None:
    room_id = get_player_room(event.player_id)
    await broadcast_to_room(
        room_id,
        f"Player leveled up to {event.new_level}!"
    )

async def award_level_up_bonus(event: PlayerLeveledUpEvent) -> None:
    player = world.get_entity(event.player_id)
    # Award stat points, etc.

Aggregation Pattern

Collect data from multiple handlers:

@dataclass
class DamageCalculationEvent(Event):
    """Calculate total damage with modifiers."""
    attacker_id: UUID
    target_id: UUID
    base_damage: int
    modifiers: dict[str, int] = field(default_factory=dict)

    @property
    def total_damage(self) -> int:
        return max(0, self.base_damage + sum(self.modifiers.values()))

# Each handler adds its modifier
async def apply_strength_bonus(event: DamageCalculationEvent) -> None:
    attacker = world.get_entity(event.attacker_id)
    stats = attacker.try_get(StatsComponent)
    if stats:
        event.modifiers["strength"] = stats.strength // 2

async def apply_weapon_bonus(event: DamageCalculationEvent) -> None:
    attacker = world.get_entity(event.attacker_id)
    weapon = get_equipped_weapon(attacker)
    if weapon:
        event.modifiers["weapon"] = weapon.damage_bonus

async def apply_armor_reduction(event: DamageCalculationEvent) -> None:
    target = world.get_entity(event.target_id)
    armor = target.try_get(ArmorComponent)
    if armor:
        event.modifiers["armor"] = -armor.defense

# Usage
event = DamageCalculationEvent(
    attacker_id=attacker.id,
    target_id=target.id,
    base_damage=10,
)
await events.emit(event)
actual_damage = event.total_damage  # Includes all modifier contributions

Organizing Events

File Structure

my-content-pack/
└── src/
    └── my_pack/
        ├── __init__.py
        ├── pack.py
        └── events/
            ├── __init__.py
            ├── combat.py    # Combat events
            ├── items.py     # Item events
            ├── quests.py    # Quest events
            └── social.py    # Social events

Event Module

# my_pack/events/combat.py
from dataclasses import dataclass, field
from uuid import UUID
from maid_engine.core.events import Event

@dataclass
class AttackEvent(Event):
    """Player or NPC attacks a target."""
    attacker_id: UUID
    target_id: UUID
    skill_name: str = "basic_attack"

@dataclass
class DamageEvent(Event):
    """Damage is dealt to a target."""
    target_id: UUID
    damage: int
    damage_type: str = "physical"
    source_id: UUID | None = None

@dataclass
class DeathEvent(Event):
    """An entity dies."""
    entity_id: UUID
    killer_id: UUID | None = None

Package Exports

# my_pack/events/__init__.py
from .combat import AttackEvent, DamageEvent, DeathEvent
from .items import ItemPickedUpEvent, ItemDroppedEvent
from .quests import QuestStartedEvent, QuestCompletedEvent

__all__ = [
    # Combat
    "AttackEvent",
    "DamageEvent",
    "DeathEvent",
    # Items
    "ItemPickedUpEvent",
    "ItemDroppedEvent",
    # Quests
    "QuestStartedEvent",
    "QuestCompletedEvent",
]

Documentation

Documenting Events

@dataclass
class CriticalHitEvent(Event):
    """Emitted when a critical hit occurs in combat.

    This event is emitted after damage calculation but before
    damage application. Handlers can:
    - Modify the critical multiplier
    - Add special effects
    - Cancel to prevent the critical (damage still applies normally)

    Attributes:
        attacker_id: Entity that scored the critical hit
        target_id: Entity receiving the critical hit
        base_damage: Damage before critical multiplier
        multiplier: Critical damage multiplier (default 2.0)

    Example:
        async def on_critical(event: CriticalHitEvent) -> None:
            # Apply assassin bonus
            if has_skill(event.attacker_id, "assassin"):
                event.multiplier = 3.0

    Related Events:
        - DamageEvent: Emitted after critical is applied
        - DeathEvent: May be emitted if target dies
    """
    attacker_id: UUID
    target_id: UUID
    base_damage: int
    multiplier: float = 2.0

    @property
    def total_damage(self) -> int:
        """Damage after critical multiplier."""
        return int(self.base_damage * self.multiplier)

Best Practices

1. Use Clear, Descriptive Names

# Good - clear what happened
class PlayerJoinedGuildEvent(Event): ...
class ItemEnchantedEvent(Event): ...
class CombatRoundEndedEvent(Event): ...

# Bad - ambiguous
class GuildEvent(Event): ...  # What about the guild?
class ItemEvent(Event): ...   # What happened to the item?

2. Include All Necessary Context

# Good - all context included
@dataclass
class TradeCompletedEvent(Event):
    trader_a_id: UUID
    trader_b_id: UUID
    items_a_to_b: list[UUID]
    items_b_to_a: list[UUID]
    gold_a_to_b: int
    gold_b_to_a: int
    room_id: UUID
    timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))

# Bad - missing context
@dataclass
class TradeCompletedEvent(Event):
    trade_id: UUID  # What was traded? Who traded?

3. Keep Events Immutable Where Possible

# Good - mostly immutable, explicit mutable fields
@dataclass
class DamageCalculationEvent(Event):
    # Immutable
    attacker_id: UUID
    target_id: UUID
    base_damage: int

    # Explicitly mutable for modifier pattern
    modifiers: dict[str, int] = field(default_factory=dict)

4. Use Optional Fields with Defaults

@dataclass
class ItemCraftedEvent(Event):
    crafter_id: UUID
    item_id: UUID
    recipe_id: str
    # Optional context
    quality: str = "normal"
    bonus_stats: dict[str, int] = field(default_factory=dict)
    critical_craft: bool = False

5. Test Your Events

def test_damage_event_creation():
    event = DamageEvent(
        target_id=UUID("12345678-1234-1234-1234-123456789abc"),
        damage=50,
    )

    assert event.damage == 50
    assert event.source_id is None
    assert event.event_type == "DamageEvent"
    assert not event.cancelled

def test_damage_event_cancellation():
    event = DamageEvent(
        target_id=UUID("12345678-1234-1234-1234-123456789abc"),
        damage=50,
    )

    event.cancel()

    assert event.cancelled

Next Steps