The inheritance chain from DamageTarget to WarmanPlayer is four levels deep. That is not over-engineering. Each level has a specific contract, and nothing from a lower layer belongs in a higher one.

DamageTarget: the minimum hittable surface

DamageTarget is the base for everything that can take damage. It establishes that a thing has a room it belongs to, can be hit, and can be removed from the game. That is the complete contract.

Destructible extends DamageTarget directly, not Unit. Barrels, wall sections, and breakable objects take damage and break. They do not regen health, move, cast skills, or enter combat. Giving them Unit would mean carrying around a hundred properties they will never use. DamageTarget is the right base. The cursor style changes to an attack icon when hovering over a destructible, so players know it is targetable. That is the entire feature set.

DamageTarget hittable surface, room membership Destructible no stats, breaks on damage Unit stats, health, damage pipeline, modifiers WarmanPlayer skills, items, companions, UI contexts Enemy presets, elite scaling, soul drops
Destructible branches directly from DamageTarget. Unit adds the full stat surface. WarmanPlayer and Enemy diverge from there.

Unit: the stat surface and observable state

Unit adds the full stat surface: health, stamina, armor, resistance, movement speed, physical and magical damage, crit chance, dodge, deflect, cooldown multiplier, and around twenty more. Most stats are plain float properties with a Real* computed variant that folds in percent multipliers:

public float Armor { get; set; }
public float ArmorPercent { get; set; } = 100f;
public float RealArmor => Armor * (ArmorPercent / 100);

State that other systems need to react to is Observable<T>. CurrentHealth, IsAlive, MaxHealth, IsBeingChased: these are not plain properties. They carry a Changed event that subscribers receive when the value is posted. The health bar UI subscribes to CurrentHealth.Changed. The camera subscribes to IsBeingChased.Changed to switch music. Nothing polls; everything reacts.

The damage pipeline

ServerDealDamage handles the full hit resolution in one function. Every attack, trap, projectile, reflect, and ground effect goes through it. It runs inside ForwardSimulation and creates a MasterRandom from a tick-derived seed at the start, ensuring every random roll produces identical results on every client.

The first check is the damage group cooldown. A damageGroupId identifies a projectile or area effect group. Once a target has been hit by that group, it cannot be hit again for 0.5 seconds. This prevents a cluster of simultaneous projectiles from all landing at once.

DamageGroup + DamageSnapshot + DamageOrigin CanBeHitByGroup? No return Dodge / Deflect roll melee/skill = Dodge | projectile = Deflect | trap/reflect = 0 Dodged MISS popup Block roll Blocked damage = 1 Calculate ticks + ability mult + crit + elite + species + variance Reduce % damage reduction + penetrated armor/resistance Clamp [1, 9999] → apply → lifesteal / reflect / chain
Every hit in the game resolves through this sequence. All random rolls share one MasterRandom instance seeded per tick.

After the group cooldown check, DamageType sets the defensive values that apply. Physical hits reduce against armor and can be blocked by shields. Magical hits reduce against resistance. Pure hits bypass all of it: zero flat reduction, zero block chance, full penetration. Traps and reflected damage are pure.

DamageOrigin determines the avoidance stat:

case DamageOrigin.PrimaryAttack:
case DamageOrigin.Skill:
    dodgeChance = CappedDodge;    break;

case DamageOrigin.Projectile:
case DamageOrigin.ProjectileFromSkill:
    dodgeChance = CappedDeflect;  break;

case DamageOrigin.Trap:
case DamageOrigin.Reflect:
case DamageOrigin.ChainLightning:
default:
    dodgeChance = 0;              break;

Melee and skill hits use Dodge. Projectiles use Deflect. Traps, reflect, chain lightning, modifiers, and ground effects bypass both. This makes Dodge and Deflect meaningfully different stats, not two names for the same thing.

After dodge and block rolls, raw damage is assembled: sum all DamageTick entries in the group (flat damage, plus percent of attacker stat, plus optional percent of target health), multiply by the ability damage multiplier if the origin scales with abilities, apply crit, apply the elite multiplier if the target is elite, apply the species multiplier, then vary by plus or minus twenty percent using MasterRandom.

Flat reduction from armor or resistance applies with penetration:

var penetratedReduction = flatReduction * (100 - Mathf.Clamp(penetration, 0, 100)) / 100;
var rolledReduction = random.RollChance(penetratedReduction % 1 * 100)
    ? Mathf.CeilToInt(penetratedReduction)
    : Mathf.FloorToInt(penetratedReduction);
damage -= rolledReduction;

The probabilistic rounding avoids systematic bias. If penetrated reduction works out to 3.7, there is a 70% chance it rounds to 4 and a 30% chance it rounds to 3. Always flooring would give attackers a consistent edge; always ceiling would give defenders one. The random round converges to the real value over many hits.

The final integer is clamped to [1, MaximumAllowedDamage] (9999). Damage is never zero. After applying, post-damage effects run: lifesteal and spell steal heal the attacker, damage reflect fires pure damage back to the source, and chain lightning may propagate. Each checks DamageOrigin against an allow-list before firing: reflected damage cannot reflect, chain lightning cannot chain.

DamageSnapshot and why the source is captured early

Damage is not applied immediately when an attack lands. It is added to a queue via WarmanServer.AddDamageAction() and processed in batch at the top of the next tick in UpdateDamageQueue(). The source unit might be dead or modified by the time the queue processes. DamageSnapshot captures the attacker's relevant stats at the moment the hit is initiated:

public DamageSnapshot CreateDamageSnapshot()
{
    return new DamageSnapshot()
    {
        RealPhysicalDamage = RealPhysicalDamage,
        RealMagicalDamage  = RealMagicalDamage,
        PhysicalDamagePenetration = PhysicalDamagePenetration,
        MagicalDamagePenetration  = MagicalDamagePenetration,
        CritChance         = CritChance,
        CritMultiplier     = CritMultiplier,
        AbilityDamageMultiplier = GetAbilityDamageMultiplier(),
        DamageAgainstElites = DamageAgainstElites.Data,
        // per-species multipliers...
    };
}

The calculation in ServerDealDamage reads from the snapshot, not from the source. If the attacker dies between queuing and processing, the damage still resolves with their stats at the time of the hit.

Combat state as modifier application

Whether a unit is in combat is tracked through IsBeingChased, an Observable<bool>. When an enemy acquires aggro, it calls unit.OnAcquireAggro(enemy), which adds the enemy to a list and posts IsBeingChased = true if the list was empty. When all aggressors release, IsBeingChased goes false and UpdateCombatState runs:

public virtual void UpdateCombatState()
{
    if (IsInCombat())
    {
        if (HasModifier(WarmanServer.modifierOutOfCombat))
            _combatStateModifier?.Remove(false);
        if (!HasModifier(WarmanServer.modifierInCombat))
            _combatStateModifier = ApplyModifier(this, WarmanServer.modifierInCombat);
    }
    else
    {
        if (HasModifier(WarmanServer.modifierInCombat))
            _combatStateModifier?.Remove(false);
        if (!HasModifier(WarmanServer.modifierOutOfCombat))
            _combatStateModifier = ApplyModifier(this, WarmanServer.modifierOutOfCombat);
    }
}

The in-combat modifier increments DisableCombatRegenSource. The regen tick checks that counter and skips combat regen when it is non-zero. The out-of-combat modifier enables the stronger passive regen. No special combat flag exists anywhere in the regen code. The modifier system handles it, and any other modifier can interact with the same counters if the design requires it.

Immortality windows

Three animation states make a unit temporarily immune to all damage: room transition, map transition, and room teleport. The property combines them with a float counter for modifier-based immortality:

private bool IsUnitImmortal => ImmortalSources > 0 ||
                               IsInRoomTransitionAnimation ||
                               IsInMapTransitionAnimation ||
                               IsInRoomTeleportAnimation;

ImmortalSources is a float counter rather than a boolean so that multiple concurrent immortality sources work correctly. A transition animation and an active skill granting immunity simultaneously do not cancel each other when one expires. The unit stays immortal until all sources clear. While immortal, incoming damage shows a MISS popup but applies nothing. Aggro deliberately does not check IsUnitImmortal: an enemy should still aggro a player who is mid-dodge-roll, even though the dodge has made them temporarily immune.

WarmanPlayer and Enemy: what each layer adds

WarmanPlayer adds equipment slots and a sixty-slot backpack, four skill slots with mastery trees, and the context observables that drive the UI (CurrentBackpack, CurrentShop, CurrentMastery, and others). It can own AI companions, which are themselves full WarmanPlayer instances driven by script-controlled input rather than player input. The ownership is recursive: a companion can own sub-companions.

Enemy adds spawn configuration, elite stat scaling applied at spawn time, soul drop values (experience and gold), and the aggro-to-player logic. It maintains a TargetToChase reference and updates when the target dies. The LifeBoundChildren list in Unit handles summoned units: when a parent enemy dies, UnitOnDeath kills every enemy in the list. Summoned units exist only as long as their parent does, and the parent cleans them up rather than leaving that to the room.