Skip to content

Combat System Tutorial - Part 2: Components

In this part, you will create the combat-related components that store offensive and defensive statistics for entities.

Overview

Components in MAID are pure data containers that attach to entities. For our combat system, we need:

  • AttackComponent: Stores offensive stats (attack power, accuracy, critical chance)
  • DefenseComponent: Stores defensive stats (defense rating, evasion, damage reduction)

The HealthComponent is already provided by maid-stdlib, so we will use that for tracking hit points.

Creating the Components

AttackComponent

The AttackComponent tracks everything related to dealing damage:

# src/maid_combat_system/components/combat.py
"""Combat-related components."""

from typing import ClassVar

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


class AttackComponent(Component):
    """Component for entities that can attack.

    This component stores offensive statistics used in combat calculations.
    Attach this to any entity that should be able to deal damage.

    Attributes:
        attack_power: Base damage dealt before modifiers
        accuracy: Percentage chance to hit (0-100)
        critical_chance: Percentage chance for critical hit (0-100)
        critical_multiplier: Damage multiplier on critical hits
        attack_speed: Attacks per round (for future use)
        damage_type: Type of damage dealt (physical, magical, etc.)
    """

    component_type: ClassVar[str] = "AttackComponent"

    # Core offensive stats
    attack_power: int = Field(default=10, ge=0, description="Base damage dealt")
    accuracy: int = Field(default=80, ge=0, le=100, description="Hit chance percentage")

    # Critical hit stats
    critical_chance: int = Field(default=5, ge=0, le=100, description="Crit chance percentage")
    critical_multiplier: float = Field(default=2.0, gt=0, description="Crit damage multiplier")

    # Additional combat stats
    attack_speed: float = Field(default=1.0, gt=0, description="Attacks per round")
    damage_type: str = Field(default="physical", description="Type of damage dealt")

    # Combat state
    last_attack_time: float = Field(default=0.0, description="Timestamp of last attack")

    def calculate_base_damage(self) -> int:
        """Calculate base damage before defense.

        Returns:
            The base damage value from attack_power.
        """
        return self.attack_power

    def roll_hit(self, random_value: int) -> bool:
        """Determine if an attack hits.

        Args:
            random_value: A random number from 1-100

        Returns:
            True if the attack hits, False if it misses.
        """
        return random_value <= self.accuracy

    def roll_critical(self, random_value: int) -> bool:
        """Determine if an attack is a critical hit.

        Args:
            random_value: A random number from 1-100

        Returns:
            True if the attack is a critical hit.
        """
        return random_value <= self.critical_chance

    def calculate_critical_damage(self, base_damage: int) -> int:
        """Calculate damage with critical hit multiplier.

        Args:
            base_damage: The base damage before critical multiplier

        Returns:
            The damage after applying the critical multiplier.
        """
        return int(base_damage * self.critical_multiplier)


class DefenseComponent(Component):
    """Component for entities that can defend against attacks.

    This component stores defensive statistics used in combat calculations.
    Attach this to any entity that should be able to reduce incoming damage.

    Attributes:
        defense: Flat damage reduction applied to incoming attacks
        evasion: Percentage chance to completely dodge an attack (0-100)
        damage_reduction: Percentage of damage reduced after defense (0-100)
        resistances: Damage type resistances (type -> percentage reduction)
        armor_type: Type of armor worn (for future equipment integration)
    """

    component_type: ClassVar[str] = "DefenseComponent"

    # Core defensive stats
    defense: int = Field(default=5, ge=0, description="Flat damage reduction")
    evasion: int = Field(default=10, ge=0, le=100, description="Dodge chance percentage")

    # Percentage-based reduction
    damage_reduction: int = Field(default=0, ge=0, le=100, description="Percentage damage reduction")

    # Resistances by damage type
    resistances: dict[str, int] = Field(
        default_factory=dict,
        description="Damage type resistances (percentage reduction)"
    )

    # Armor info
    armor_type: str = Field(default="none", description="Type of armor worn")

    def roll_evasion(self, random_value: int) -> bool:
        """Determine if an attack is evaded.

        Args:
            random_value: A random number from 1-100

        Returns:
            True if the attack is dodged, False otherwise.
        """
        return random_value <= self.evasion

    def calculate_damage_taken(
        self,
        incoming_damage: int,
        damage_type: str = "physical"
    ) -> int:
        """Calculate actual damage taken after defenses.

        The calculation order is:
        1. Apply flat defense reduction
        2. Apply percentage damage reduction
        3. Apply damage type resistance

        Args:
            incoming_damage: Raw damage before defenses
            damage_type: Type of incoming damage

        Returns:
            The actual damage taken after all reductions.
            Minimum of 1 damage is always dealt.
        """
        # Start with incoming damage
        damage = incoming_damage

        # Apply flat defense
        damage = max(0, damage - self.defense)

        # Apply percentage damage reduction
        if self.damage_reduction > 0:
            reduction = damage * self.damage_reduction / 100
            damage = int(damage - reduction)

        # Apply damage type resistance
        resistance = self.resistances.get(damage_type, 0)
        if resistance > 0:
            resistance_reduction = damage * resistance / 100
            damage = int(damage - resistance_reduction)

        # Ensure minimum damage of 1 if there was any incoming damage
        if incoming_damage > 0:
            return max(1, damage)
        return 0

    def get_effective_defense(self, damage_type: str = "physical") -> int:
        """Get total effective defense against a damage type.

        This combines flat defense with resistance percentage for display purposes.

        Args:
            damage_type: The type of damage to calculate defense against

        Returns:
            An estimated effective defense value.
        """
        base = self.defense
        resistance = self.resistances.get(damage_type, 0)
        # Add 1 effective defense per 10% resistance
        return base + (resistance // 10)

Update the Components Init

Update __init__.py to export the components:

# src/maid_combat_system/components/__init__.py
"""Combat-related components.

This module provides components for combat functionality:
- AttackComponent: Offensive statistics (attack power, accuracy, crit chance)
- DefenseComponent: Defensive statistics (defense, evasion, resistances)
"""

from .combat import AttackComponent, DefenseComponent

__all__ = ["AttackComponent", "DefenseComponent"]

Using the Components

Adding Components to Entities

Here's how to add combat components to entities:

from maid_combat_system.components import AttackComponent, DefenseComponent
from maid_stdlib.components import HealthComponent

# Create a warrior character
warrior = world.entities.create()
warrior.add(HealthComponent(current=100, maximum=100))
warrior.add(AttackComponent(
    attack_power=15,
    accuracy=85,
    critical_chance=10,
    damage_type="physical",
))
warrior.add(DefenseComponent(
    defense=10,
    evasion=5,
    armor_type="heavy",
))
warrior.add_tag("player")

# Create a rogue character with different stats
rogue = world.entities.create()
rogue.add(HealthComponent(current=70, maximum=70))
rogue.add(AttackComponent(
    attack_power=8,
    accuracy=95,
    critical_chance=25,
    critical_multiplier=3.0,
    damage_type="physical",
))
rogue.add(DefenseComponent(
    defense=3,
    evasion=30,
    armor_type="light",
))
rogue.add_tag("player")

# Create a monster with fire resistance
fire_elemental = world.entities.create()
fire_elemental.add(HealthComponent(current=80, maximum=80))
fire_elemental.add(AttackComponent(
    attack_power=12,
    accuracy=75,
    damage_type="fire",
))
fire_elemental.add(DefenseComponent(
    defense=5,
    evasion=15,
    resistances={"fire": 100, "cold": -50},  # Immune to fire, weak to cold
))
fire_elemental.add_tag("hostile")

Reading Component Values

# Get attack information
attacker = world.entities.get(attacker_id)
if attacker and attacker.has(AttackComponent):
    attack = attacker.get(AttackComponent)
    print(f"Attack Power: {attack.attack_power}")
    print(f"Accuracy: {attack.accuracy}%")
    print(f"Crit Chance: {attack.critical_chance}%")

# Get defense information
defender = world.entities.get(defender_id)
if defender and defender.has(DefenseComponent):
    defense = defender.get(DefenseComponent)
    print(f"Defense: {defense.defense}")
    print(f"Evasion: {defense.evasion}%")
    print(f"Fire Resistance: {defense.resistances.get('fire', 0)}%")

Optional Components with try_get

Not all entities need all components. Use try_get for optional components:

entity = world.entities.get(entity_id)

# AttackComponent is optional - some entities can't attack
attack = entity.try_get(AttackComponent)
if attack:
    damage = attack.calculate_base_damage()
else:
    damage = 0  # Entity can't attack

# DefenseComponent is optional - some entities have no defenses
defense = entity.try_get(DefenseComponent)
if defense:
    actual_damage = defense.calculate_damage_taken(damage)
else:
    actual_damage = damage  # No damage reduction

Component Validation

Pydantic validates component fields automatically:

# This works
valid_attack = AttackComponent(
    attack_power=20,
    accuracy=90,
    critical_chance=15,
)

# This raises ValidationError - accuracy must be 0-100
try:
    invalid_attack = AttackComponent(accuracy=150)
except ValueError as e:
    print(f"Validation error: {e}")

# This raises ValidationError - attack_power must be >= 0
try:
    invalid_attack = AttackComponent(attack_power=-5)
except ValueError as e:
    print(f"Validation error: {e}")

Serialization

Components can be serialized for persistence:

# Serialize to dictionary
attack = AttackComponent(attack_power=15, critical_chance=20)
data = attack.model_dump()
# {'attack_power': 15, 'accuracy': 80, 'critical_chance': 20, ...}

# Deserialize from dictionary
restored = AttackComponent.model_validate(data)
assert restored.attack_power == 15

Damage Calculation Example

Here's a complete damage calculation flow using both components:

import random


def calculate_combat_damage(
    attacker,
    defender,
    verbose: bool = False
) -> tuple[int, bool, bool]:
    """Calculate damage from attacker to defender.

    Args:
        attacker: Entity with AttackComponent
        defender: Entity with HealthComponent and optional DefenseComponent
        verbose: Whether to print calculation steps

    Returns:
        Tuple of (damage_dealt, was_critical, was_evaded)
    """
    attack = attacker.get(AttackComponent)
    defense = defender.try_get(DefenseComponent)

    # Roll for evasion first
    if defense:
        evasion_roll = random.randint(1, 100)
        if defense.roll_evasion(evasion_roll):
            if verbose:
                print(f"Attack evaded! (rolled {evasion_roll} vs {defense.evasion}%)")
            return (0, False, True)

    # Roll for hit
    hit_roll = random.randint(1, 100)
    if not attack.roll_hit(hit_roll):
        if verbose:
            print(f"Attack missed! (rolled {hit_roll} vs {attack.accuracy}%)")
        return (0, False, False)

    # Calculate base damage
    base_damage = attack.calculate_base_damage()

    # Roll for critical
    crit_roll = random.randint(1, 100)
    was_critical = attack.roll_critical(crit_roll)
    if was_critical:
        damage = attack.calculate_critical_damage(base_damage)
        if verbose:
            print(f"Critical hit! {base_damage} -> {damage}")
    else:
        damage = base_damage

    # Apply defenses
    if defense:
        final_damage = defense.calculate_damage_taken(damage, attack.damage_type)
        if verbose:
            print(f"After defenses: {damage} -> {final_damage}")
    else:
        final_damage = damage

    return (final_damage, was_critical, False)

Testing Components

Create tests for your components:

# tests/test_components.py
"""Tests for combat components."""

import pytest

from maid_combat_system.components import AttackComponent, DefenseComponent


class TestAttackComponent:
    """Tests for AttackComponent."""

    def test_default_values(self):
        """Test default component values."""
        attack = AttackComponent()
        assert attack.attack_power == 10
        assert attack.accuracy == 80
        assert attack.critical_chance == 5
        assert attack.critical_multiplier == 2.0

    def test_custom_values(self):
        """Test component with custom values."""
        attack = AttackComponent(
            attack_power=25,
            accuracy=90,
            critical_chance=15,
            critical_multiplier=2.5,
        )
        assert attack.attack_power == 25
        assert attack.accuracy == 90
        assert attack.critical_chance == 15
        assert attack.critical_multiplier == 2.5

    def test_roll_hit_success(self):
        """Test hit roll succeeds when roll is under accuracy."""
        attack = AttackComponent(accuracy=80)
        assert attack.roll_hit(80) is True
        assert attack.roll_hit(1) is True

    def test_roll_hit_failure(self):
        """Test hit roll fails when roll is over accuracy."""
        attack = AttackComponent(accuracy=80)
        assert attack.roll_hit(81) is False
        assert attack.roll_hit(100) is False

    def test_roll_critical(self):
        """Test critical hit roll."""
        attack = AttackComponent(critical_chance=10)
        assert attack.roll_critical(10) is True
        assert attack.roll_critical(11) is False

    def test_calculate_critical_damage(self):
        """Test critical damage calculation."""
        attack = AttackComponent(critical_multiplier=2.0)
        assert attack.calculate_critical_damage(10) == 20
        assert attack.calculate_critical_damage(15) == 30

    def test_validation_accuracy_bounds(self):
        """Test accuracy validation."""
        with pytest.raises(ValueError):
            AttackComponent(accuracy=101)
        with pytest.raises(ValueError):
            AttackComponent(accuracy=-1)


class TestDefenseComponent:
    """Tests for DefenseComponent."""

    def test_default_values(self):
        """Test default component values."""
        defense = DefenseComponent()
        assert defense.defense == 5
        assert defense.evasion == 10
        assert defense.damage_reduction == 0

    def test_roll_evasion(self):
        """Test evasion roll."""
        defense = DefenseComponent(evasion=20)
        assert defense.roll_evasion(20) is True
        assert defense.roll_evasion(21) is False

    def test_calculate_damage_taken_flat_defense(self):
        """Test flat defense reduces damage."""
        defense = DefenseComponent(defense=5)
        assert defense.calculate_damage_taken(20) == 15
        assert defense.calculate_damage_taken(10) == 5
        # Minimum damage is 1
        assert defense.calculate_damage_taken(5) == 1

    def test_calculate_damage_taken_percentage_reduction(self):
        """Test percentage damage reduction."""
        defense = DefenseComponent(defense=0, damage_reduction=50)
        assert defense.calculate_damage_taken(20) == 10
        assert defense.calculate_damage_taken(10) == 5

    def test_calculate_damage_taken_resistance(self):
        """Test damage type resistance."""
        defense = DefenseComponent(
            defense=0,
            resistances={"fire": 50}
        )
        assert defense.calculate_damage_taken(20, "fire") == 10
        assert defense.calculate_damage_taken(20, "cold") == 20

    def test_calculate_damage_taken_combined(self):
        """Test combined defense, reduction, and resistance."""
        defense = DefenseComponent(
            defense=5,           # -5 damage
            damage_reduction=20,  # -20% of remaining
            resistances={"fire": 25},  # -25% of remaining
        )
        # 20 -> 15 (defense) -> 12 (20% reduction) -> 9 (25% resistance)
        result = defense.calculate_damage_taken(20, "fire")
        assert result == 9

    def test_minimum_damage_always_one(self):
        """Test that minimum damage is always 1 if any incoming damage."""
        defense = DefenseComponent(defense=100)
        # Even with 100 defense, still take 1 damage
        assert defense.calculate_damage_taken(10) == 1
        # But 0 incoming damage = 0 damage
        assert defense.calculate_damage_taken(0) == 0

Run the tests:

uv run pytest tests/test_components.py -v

Summary

In this part, you:

  1. Created AttackComponent with offensive statistics
  2. Created DefenseComponent with defensive statistics
  3. Implemented damage calculation methods
  4. Learned how to use optional components with try_get
  5. Created comprehensive tests for the components

Next Steps

In Part 3: System, you will create the CombatSystem that:

  • Processes attack requests
  • Calculates and applies damage
  • Handles entity death
  • Emits combat events

Continue to Part 3: System