This D&D 5e game engine is built on a sophisticated event-driven architecture with component-based entities. The system models D&D mechanics through several interacting subsystems:
- Registry System: UUID-based global object registry for all game objects
- Entity-Component Framework: Entities composed of specialized component "blocks"
- Value System: Modifiable values with multiple modification sources
- Event System: Event-driven architecture for game state changes
- Condition System: Effects that modify entities through subconditions
- Action Framework: Structured approach to character actions
The fundamental principle is that all game state changes flow through events, allowing for interception, modification, and reaction at every stage.
All game objects are globally accessible through a UUID-based registry system.
- Every game object has a unique UUID that serves as its global identifier
- Objects register themselves in class-level registries upon creation
- Any object can be retrieved from anywhere using its UUID
- This enables decoupled communication between components
class BaseObject(BaseModel):
uuid: UUID = Field(default_factory=uuid4)
_registry: ClassVar[Dict[UUID, 'BaseObject']] = {}
def __init__(self, **data):
super().__init__(**data)
self.__class__._registry[self.uuid] = self
@classmethod
def get(cls, uuid: UUID) -> Optional['BaseObject']:
return cls._registry.get(uuid)
This pattern enables any object to look up any other object without direct references, which is foundational for the event system.
The value system is the foundation for all game mechanics.
ModifiableValue
is the core building block that represents any value that can be modified (ability scores, AC, saving throws, etc.).
Key features:
- Base value that can be modified through different channels
- Multiple modification sources with different priorities
- Support for special statuses (advantage, critical, auto-hit)
- Propagation of effects between entities
Each value has four channels for modifications:
- self_static: Direct modifiers applied to the value
- self_contextual: Context-dependent modifiers based on situation
- to_target_static: Outgoing modifiers applied to others
- to_target_contextual: Outgoing situational modifiers
ModifiableValue
├── self_static (always applies to self)
├── self_contextual (applies to self based on context)
├── to_target_static (always applies to targets)
└── to_target_contextual (applies to targets based on context)
The system supports different types of modifiers:
- NumericalModifier: Changes a value by addition
- AdvantageModifier: Applies advantage or disadvantage
- CriticalModifier: Modifies critical hit chances
- AutoHitModifier: Forces automatic hits or misses
- ResistanceModifier: Changes damage resistances/vulnerabilities
Values can interact across entities using set_from_target()
and reset_from_target()
:
# During an attack
target_ac.set_from_target(attacker_bonus) # AC affected by attacker's abilities
attacker_bonus.set_from_target(target_ac) # Attack roll affected by target's defenses
Entities are composed of specialized component "blocks" that provide different functionality.
All blocks inherit from BaseBlock
, which provides:
- Registry integration
- Value management
- Target propagation
- Context handling
Entity
├── ability_scores (STR, DEX, CON, INT, WIS, CHA)
├── skill_set (Perception, Stealth, etc.)
├── saving_throws (Saving throw capabilities)
├── health (HP, damage handling)
├── equipment (Weapons, armor, items)
├── action_economy (Actions, bonus actions, reactions)
├── senses (Position, vision, awareness)
└── active_conditions (Current effects on entity)
Components interact through:
- Direct method calls when immediate response is needed
- Event system for reactions and interrupts
- Value cross-propagation for mutual effects
Each component specializes in one aspect of game mechanics:
- AbilityScores: Base attributes and modifiers
- SkillSet: Skill proficiencies and bonuses
- Health: Damage tracking and resistances
- Equipment: Weapons, armor, and item effects
- ActionEconomy: Action resource management
The event system is the nervous system of the game engine, enabling decoupled communication between components.
Events are self-contained objects that represent something happening in the game:
class Event(BaseObject):
name: str
event_type: EventType # ATTACK, MOVEMENT, DAMAGE, etc.
phase: EventPhase # DECLARATION, EXECUTION, EFFECT, COMPLETION
source_entity_uuid: UUID
target_entity_uuid: Optional[UUID]
parent_event: Optional[UUID]
# Event-specific data...
Events progress through phases:
- DECLARATION: Initial intent (e.g., "I want to attack")
- EXECUTION: Action execution (e.g., rolling dice)
- EFFECT: Applying effects (e.g., dealing damage)
- COMPLETION: Finalizing (e.g., updating state)
DECLARATION → EXECUTION → EFFECT → COMPLETION
Events transition using phase_to()
, which creates a new event with updated phase and data:
def phase_to(self, new_phase: EventPhase, **kwargs) -> 'Event':
updated_data = self.model_dump()
updated_data.update(kwargs)
updated_data["phase"] = new_phase
updated_data["modified"] = True
return self.__class__(**updated_data)
Event handlers respond to specific event patterns:
class EventHandler(BaseObject):
trigger_conditions: List[Trigger]
handler_function: EventProcessor
entity_uuid: UUID
Triggers define when handlers activate:
class Trigger:
event_types: List[EventType]
phases: List[EventPhase]
source_entity_uuids: Optional[List[UUID]]
target_entity_uuids: Optional[List[UUID]]
# Other conditions...
Handlers are registered with entities:
def add_event_handler(self, event_handler: EventHandler) -> None:
self.event_handlers[event_handler.uuid] = event_handler
for trigger in event_handler.trigger_conditions:
self.event_handlers_by_trigger[trigger].append(event_handler)
Conditions are effects applied to entities (like Blinded, Charmed, Raging).
The key insight is that conditions use a parent-child hierarchy:
BaseCondition (parent)
└── SubConditions (children)
The parent condition:
- Manages the overall effect lifecycle
- Registers event handlers
- Tracks subconditions
The subconditions:
- Apply actual modifiers to the entity
- Are removed when the parent is removed
- Can be added/removed dynamically
Conditions must be registered with their parent:
def _apply(self, event: Event) -> Tuple[List[Tuple[UUID,UUID]],List[UUID],List[UUID],Optional[Event]]:
# Returns:
# - List of (block_uuid, modifier_uuid) tuples
# - List of event handler UUIDs
# - List of subcondition UUIDs
# - Modified event
This is the critical pattern for dynamic condition behavior:
- Parent condition sets up event handlers during application
- Event handlers create events that trigger application/removal of subconditions
- Subconditions apply the actual modifiers to the entity
Example flow:
1. AdaptiveArmorCondition applies
└── Sets up "damage taken" event handler
└── Creates AdaptiveArmorBaseCondition (basic AC bonus)
2. Entity takes fire damage
└── Event handler triggers
└── Creates a "apply resistance" event
3. "Apply resistance" event
└── Removes old resistance subcondition (if any)
└── Creates FireResistanceCondition subcondition
When a parent condition is removed:
- It removes all registered subconditions
- It removes all registered event handlers
- Each subcondition removes its modifiers
This ensures clean cleanup and prevents dangling effects.
CRITICAL POINT: Conditions themselves should be immutable after application. Any state changes must happen through the creation/removal of subconditions.
❌ WRONG:
def event_handler(event, entity_uuid):
condition.some_value += 1 # WRONG! Directly modifying condition state
✅ CORRECT:
def event_handler(event, entity_uuid):
# Create an event that will create/remove subconditions
new_event = Event(...)
subcondition = SomeSubcondition(...)
subcondition.apply(new_event)
When event handlers need to maintain state between calls, use the partial
function:
from functools import partial
# During condition application
image_iterator = cycle(subcondition_uuids)
handler = EventHandler(
handler_function=partial(self.handle_missed_attack, image_iterator)
)
This ensures the state is tied to the specific event handler instance.
Actions represent complex player/monster abilities.
class Action(BaseObject):
name: str
description: str
prerequisites: OrderedDict[str, EventProcessor] # Checks before action
consequences: OrderedDict[str, EventProcessor] # Effects of action
cost_type: CostType # actions, bonus_actions, reactions, movement
cost: int
- Declaration: Create an event declaring intent
- Prerequisites: Check if action can be performed
- Consequences: Apply effects in order
- Revalidation: Check prerequisites again after each consequence (optional)
def apply(self, parent_event: Optional[Event] = None) -> Optional[Event]:
# 1. Declare action
event = self.create_declaration_event(parent_event)
# 2. Check prerequisites
if event.canceled:
return event
event = self.check_prerequisites(event)
# 3. Apply consequences if prerequisites met
if event.canceled:
return event
return self.apply_consequences(event)
Actions automatically handle event phases:
def apply_consequences(self, event: Event) -> Optional[Event]:
# Move to EXECUTION phase
event = event.phase_to(EventPhase.EXECUTION)
# Apply each consequence in sequence
for consequence_name, consequence_func in self.consequences.items():
event = consequence_func(event, event.source_entity_uuid)
if event.canceled:
return event
# Optional revalidation of prerequisites
if self.revalidate_prerequisites:
event = self.check_prerequisites(event)
if event.canceled:
return event
# Move to COMPLETION phase
return event.phase_to(EventPhase.COMPLETION)
Understanding how these systems work together is key to creating complex interactions.
A typical game action flows through the system like this:
Action
└── Creates Declaration Event
└── Triggers Event Handlers
└── May Create/Modify Conditions
└── Apply Modifiers to Values
└── Affect Dice Rolls/Outcomes
└── Determine Action Results
└── Apply Effects
└── Trigger More Events
Parent Condition
├── Creates Event Handlers (on apply)
└── Handlers Create/Remove Subconditions (on events)
└── Subconditions Apply/Remove Modifiers (on apply/remove)
Example: Adaptive Armor responding to damage types
Parent Condition
├── Creates Event Handlers (on apply)
│ └── Handlers Track Progress/State
│ └── Create New Subconditions with Greater Effect
└── Subconditions Apply Bonuses Based on Level (on apply)
Example: Battle trance improving with successive hits
Parent Condition
├── Applies Base Effect (on apply)
└── Creates Event Handlers (on apply)
└── Handlers Trigger Special Effect Subconditions (on events)
└── Effect Subconditions Apply Short-term Modifiers (on apply)
Example: Counterspelling a spell being cast
-
Separation of Concerns
- Parent conditions handle lifecycle and event registration
- Subconditions handle actual modification application
- Event handlers coordinate subcondition creation/removal
-
Clean Removal
- All effects must be removable
- Register all event handlers and subconditions
- Use UUIDs rather than direct references
-
State Management
- Never modify condition state after application
- Use subconditions to represent state changes
- Use
partial
for handlers that need to maintain state
-
Event Propagation
- Respect event phase progression
- Create new events rather than modifying existing ones
- Use event hierarchies (parent/child) for related effects
For conditions that interact with each other (like invisibility ending when attacking):
- Create a parent condition with subcondition for the effect
- Add event handlers that monitor for triggering events
- Have handlers remove the parent condition directly
class InvisibilitySpell(BaseCondition):
def _apply(self, event):
# Create invisibility effect subcondition
invisible = InvisibleCondition(...)
invisible.apply(event)
sub_conditions_uuids.append(invisible.uuid)
# Add event handler to end invisibility on attack
end_handler = EventHandler(
trigger_conditions=[
Trigger(event_types=[EventType.ATTACK],
phases=[EventPhase.DECLARATION],
source_entity_uuids=[self.target_entity_uuid])
],
handler_function=self.end_invisibility
)
# ... register handler
For modifiers that only apply in certain situations:
- Use contextual modifier channels
- Define functions that determine when modifier applies
- Pass these functions to the modifier registration
# Create a conditional modifier function
def only_against_dragons(source_uuid, target_uuid, context):
target = Entity.get(target_uuid)
if "dragon" in target.type.lower():
return NumericalModifier(name="Dragon Slayer", value=3)
return None
# Register with contextual channel
weapon.damage_bonus.self_contextual.add_value_modifier(
ContextualNumericalModifier(callable=only_against_dragons)
)
For effects that should last for one attack or action:
- Create a subcondition with specific duration
- Use an event handler to clean up after the event completes
- Possibly use
DurationType.ON_CONDITION
for automatic cleanup
# Create temporary condition
temp_effect = TemporaryBoostCondition(
duration=Duration(
duration=1,
duration_type=DurationType.ON_CONDITION
)
)
temp_effect.apply(event)
# Add cleanup handler
cleanup_handler = EventHandler(
trigger_conditions=[
Trigger(
event_types=[EventType.ATTACK],
phases=[EventPhase.COMPLETION],
specific_event_uuid=event.uuid # Only this specific event
)
],
handler_function=lambda e, uuid: entity.remove_condition(temp_effect.name)
)
The power of this architecture comes from the elegant way these systems interact:
- Registry System provides global object access
- Value System enables complex modification paths
- Entity-Component Framework organizes functionality
- Event System enables reactions and interactions
- Condition System allows complex, layered effects
- Action Framework structures player/monster abilities
Remember these key principles:
- All game state changes flow through events
- Conditions manage effects through subconditions
- Event handlers orchestrate condition changes
- Modifiers affect values, not conditions directly
- Clean registration ensures proper cleanup
By mastering these patterns, you can create arbitrarily complex game mechanics that accurately model D&D's rich interactions.