Damage Has a Type Now, and Some Enemies Care
Last week the game learned to agree on the same frame across machines. This week it learned that not all damage is the same. Until now a hit was just a number: subtract it from HP, done. Now every hit carries a damage type, enemies carry opinions about those types, and a tiny piece of spawn syntax lets me conjure an enemy immune to whatever I want without writing a single new class or sprite.
Nine flavors of hurt
There is a new damageTypes.json listing the vocabulary: ballistic, electric, fire, impact, explosion, energy, poison, frost, confusion. It is data, wrapped in a typed helper, and every tower is tagged with the type it deals. The Gun is ballistic, the Cannon is explosion, the Tesla is electric, the Flame is fire, the Frost tower is frost, the poison dart is poison, and so on.
The important bit is that the tag rides all the way through the shared damage pipeline. Every hit gets stamped: projectiles, beams, the Tesla's star bolts, the Flame's cone, the guillotine's execute, spike traps, and even the burn and poison damage-over-time ticks. There is exactly one dealDamage path in the game, so tagging it once means every source is typed automatically, with no per-tower special-casing.
Enemies with opinions
On the other side, enemies gained a damageResist map. Each entry is a multiplier: 0 means immune, less than 1 means resistant, 1 is normal (the default when absent), and greater than 1 means fragile. When a hit resolves, its type is looked up in the target's resist map and the damage scales by that multiplier before touching HP.
Two things fall out of this cleanly. Immunity does not just zero the damage number, it also cancels the status debuff attached to that hit: an enemy immune to frost takes no frost damage and shrugs off the slow; immune to electric means no stun; immune to poison or fire means the DoT never lands. That gating lives in one place, effectBlocked, which every apply path already consulted, so the resist map plugged straight in. And instead of a misleading "0" damage number, an immune hit floats a MISS label, driven by a replicated missCount field so it reads honestly in co-op too.
This also let me delete an ad-hoc special case. The frost elemental used to carry its fire-vulnerability as a bespoke status-resist entry; now it is just fire: 1.5 in the damageResist map, and because the map is consulted for both direct hits and DoT ticks, its burn scales up by the same 1.5 with zero extra code. One system, one rule, fewer branches.
While I was in the movement code I also wired the status-effect movement rules properly, including a spicy one: burning enemies now speed up. A creature on fire panics and rushes the exit, which is a fun little risk to the Flame tower. Slow it or burn it, but not both carelessly.
The dot-notation spawner
Here is the part I am proud of. Wave definitions spawn enemies by an id string. I extended that string to a dot notation: base.immunity1.immunity2.... So normie.fire is an ordinary normie that happens to be immune to fire; speedy.frost.electric is a fast one that laughs at both your Frost tower and your Tesla. Any base enemy, any combination of immunities, authored entirely in the wave JSON, no new enemy type required.
Under the hood the spawner parses the spec (a pure, TDD'd parseEnemyVariant) and bakes the immunities into a bitmask over the damage-type indices, stored on the enemy and replicated in co-op snapshots. dealDamage and effectBlocked consult that mask right alongside the static resist map, so an immune variant takes zero of that type and its status is cancelled, exactly like a built-in immunity.
The visuals are fully code-driven, which is the whole trick to keeping this cheap. Each damage type carries an [r,g,b] color in the JSON. An immune enemy renders a stacked colored halo, one enlarged silhouette per immunity, and a blended tint (fire plus frost blends toward purple). Adding a brand-new immunity flavor later needs one color entry and nothing else: no sprite work, no new art, no code. That is the payoff of data-driven design showing up right when you want it.
Why bother
The point of all this is to make the "there is no single best tower" pressure real. Before, you could find a favorite tower and coast. Now I can drop a fire-immune wave to punish a pure Flame build, or a frost-immune rush that ignores your slow field, and you have to actually diversify. The combat got a dimension, the enemy roster got a knob I can turn without adding entities, and the whole thing rides the one damage pipeline I already had. Back to the board.