Doug's Game Dev Log
About
2026-08-26

Towers That Pick Targets Together

Every tower in the game has an aim mode: FIRST, STRONG, WEAK. Three fixed rules, each one applied by every tower independently, none of them aware that eleven other towers are shooting the same enemy. This week I added a fourth mode, SMART, which is the first targeting rule in the game that considers the rest of the defense before it picks. It arrives as a levelable upgrade card called Smarter Towers, and it is deliberately not free or reliable.

The score

SMART is a pure scoring function over the enemies a tower can currently see, in lib/smartTargeting.ts. Every candidate starts at 1 point and then:

  • Immune to my damage type: -10. Either the damage-type multiplier is zero or the enemy's spawn-immunity mask covers my type. This is the single biggest term, because a shot into an immune enemy is worth exactly nothing.
  • Boss: +3.
  • Strong against it (multiplier above 1): +2.
  • Already targeted by other towers: -0.5 each. This is the cooperative bit. Nine towers all locking the same front enemy is the classic tower defense failure, and now the tenth one prices that in.
  • Resisted but uncovered: +1. If my damage type is weak against it (multiplier between 0 and 1) but literally nobody else is shooting it, a bad shot beats no shot.
  • Close to leaking: +3, +2 or +1 for under 15%, 25% or 50% of the path remaining.

Ties break by furthest along the path, which is exactly the FIRST rule, so SMART degrades into something familiar rather than something random.

pickSmartTarget walks the candidates, filters by range, and returns the best eid. The whole thing is pure and test-driven: the scoring never touches the ECS, it takes a candidate list carrying position, path distance, path fraction, resist map, immunity mask and a boss flag.

Reading the spec against itself

The spec I wrote for myself said to reward an enemy "less than 50% / 25% / 15% of the way through its path", as three exclusive tiers. Read literally, that rewards enemies that just spawned and are far from the leak, and it directly contradicts the acceptance criterion sitting three lines below it, which says a boss near the leak must beat a full-HP enemy far from it.

So the thresholds are implemented as path remaining, not path traveled: pathFraction runs 0 at spawn to 1 at the leak, and the tiers read 1 - pathFraction. That reading is documented at the top of the module, because a future me is going to read the spec again and wonder why the code disagrees with it.

A chance, not a guarantee

SMART is not a strictly better aim mode you switch on and forget, because that would just delete the other three. Owning the card at level 1 gives a 25% chance that any given re-selection actually uses the smart scoring; at level 100 it is 100%. The roll comes off the seeded RNG, so co-op clients agree on whether a tower got smart this time. On a failed roll it falls back to FIRST.

When a smart pick succeeds, the tower locks onto that target for four seconds before re-evaluating. Without the lock, towers oscillate: tower A picks an enemy, which raises the "already targeted" penalty, so tower B avoids it, then A re-scores and the whole formation twitches every frame. The lock costs two new per-tower fields, currentTarget and smartLockUntil, both replicated in co-op snapshots so both clients agree on the locks and therefore on the over-targeting counts that everyone else scores against.

Which towers listen

The standard, frost, poison, cone and confuse behaviors plus the beam acquisition all route through the smart lock-and-score pick. The Tesla is special: it still strikes its star pattern, but it now ranks the targets it struck by smart score. Mortars and utility towers are unchanged, since neither picks a target in a way SMART could improve.

On the UI side, AimMode grew a fourth value and everything that touches aim modes (shared-aim inheritance, the co-op aim-pending round trip, setAimMode and its per-type variant) treats it as valid. A SMART button appears in the aim menu, the inspect panel gets a row showing the current smart chance, and the HUD short label is SMRT. All of it only shows up once you actually own the card.

While I was in there: a debug console worth using

Two helpers on window.dbg that have already paid for themselves:

  • dbg.enemies() prints the bestiary as console tables: regular enemies, bosses, and the mutator tokens (fire, frost, electric) that variants use.
  • dbg.spawn(spec, quantity) drops enemies at the path start, one every 0.5s, independent of the current wave. The spec uses the same dot notation as the wave JSON, so dbg.spawn('splitter.fire', 10) gets you ten fire-immune splitters right now instead of ten minutes into a run.
  • dbg.upgrade() lists every upgrade card as a table; with an id it grants levels directly, bypassing unlock costs and the max-card rules (the shared level-100 cap still applies).

Being able to conjure the exact bad situation is the difference between testing a targeting change in five minutes and testing it in five runs.

Also housekeeping: CLAUDE.md had grown into a monolith, so it is now split into topic files under docs/, with AGENTS.md as the canonical entry point.

Next: everything about how enemies die.