Enemies Come Apart Pixel by Pixel
An enemy dying used to play a generic yellow particle poof. It was placeholder work from the very first prototype and it survived far longer than it deserved, because a thing that happens two hundred times a minute is exactly the thing worth making good. This post is about the four attempts it took to get there, the splitter's separate problem, and the graphics-quality setting that came out of a frame-rate complaint.
Four death animations in one day
Attempt one: scattered copies. A few shrinking, fading copies of the enemy's own sprite thrown outward over 150ms. Driven entirely off the dead-enemy diff that deriveFx already computes, pooled in a plain array with no engine object per death, so it stayed cheap when forty enemies die on the same frame. Sim and co-op untouched, pure presentation.
Attempt two: a real shatter. Copies of a whole sprite reads as duplication, not as breaking. So instead of whole sprites, four quadrant crops of the actual dying sprite, separating, settling and fading over 250ms. Now the enemy stays identifiable while it comes apart.
Attempt three: bigger and slower. 250ms was still a blink. Duration up to a full second, and the quadrants became a 3x3 grid: nine pieces, each flying from the center on its own random direction and speed, spinning in a random direction, and randomly growing or shrinking as it fades.
Attempt four: throw it all away. Nine tile crops with hand-rolled physics is a lot of code for something LittleJS already does. The final version is a ParticleEmitter seeded with the enemy's own tileInfo and a white (1,1,1,1) start color, which makes each particle inherit real pixel colors from the sprite. Sizes run 0.05 down to 0.01 over a one second lifetime, so the enemy reads as dissolving into its own pixels. The custom pool, updateDeathAnims and drawDeathAnims are gone entirely, and LittleJS owns the emitter lifecycle.
Four commits, net negative lines, better result. That is usually how it goes when you keep going instead of stopping at "acceptable".
The splitter needed the opposite
Splitters keep their own animation, because their death is not a disappearance, it is a transformation, and it was happening in a single frame: parent gone, two minis instantly standing there.
Now the minis are delayed one second, queued through spawnQueue rather than spawned on death. During that window a purely visual animation plays: two splitter sprites start overlapped at the death position, shrink to mini size, and drift apart along the path to the exact spots where the real minis will appear. Tester feedback killed the fade at the end, so the sprites stay fully opaque and pop out precisely when the entities arrive.
That "precisely" took one more fix. The animation timer runs on wall-clock time while the spawn used sim time (g.time + 1.0), and those two clocks can drift by up to a render frame, leaving a single frame where the animation had ended and the entities did not exist yet. A one-frame blink, extremely visible. The minis now spawn at g.time + 0.95, nineteen sim ticks instead of twenty, so they are already in the ECS during the animation's final frame.
Variant splitters also stopped laundering their immunities: when a fire-immune splitter dies, its children now inherit the parent's damage-type immunities through the spawn queue (maskToImmunities in lib/damageType, fully tested), and the split animation tints the child sprites with the blended variant color so you can see what you are about to fight.
Three tiers of graphics
A recurring playtest report said the game dips below 60fps in heavy waves. Three things were wrong, and none of them scaled with anything visible:
- The post-process bloom/grade pass, the dynamic lighting render and WebGL antialiasing were always on with no way out.
- Muzzle-flash, explosion and shield lights were uncapped, so concurrent light count scaled with tower count times fire rate. Now capped at 12.
drawEntities.tsran the[Tower, Position]and[Enemy, Position]queries up to five times per frame each. Now once.
The first shipped as a LOW GRAPHICS checkbox and immediately turned into a three-tier GRAPHICS QUALITY setting: HIGH, MEDIUM, LOW, each bundling antialiasing, the post-process pass, dynamic lighting and canvas/texture filtering. Clicking cycles the tier and applies live what can change live (dynamic lighting, canvas upscaling filter); antialiasing, post-processing and texture filtering are fixed by the engine at startup and take effect on the next reload. The choice is persisted to the save file.
Turning off the lights made everything brighter
Then LOW graphics started looking worse in an interesting way: blown out, overexposed, everything washed.
LightSystemPlugin's composite pass is what darkens the whole scene down to the stage's ambient color. Every sprite is drawn at full brightness on the assumption that the pass will run and dim it. Disabling the plugin on LOW removed the only thing dimming the scene, so the game rendered at raw full brightness. Dynamic lighting now stays on at every tier; the flash-light cap already bounds its real per-frame cost, so there was nothing left to gain by killing it. The tiers gate antialiasing, post-processing and texture filtering only. This is the second time the lighting plugin's ordering assumptions have bitten me.
And one crash worth writing down: drawOptionsOverlay read graphicsQuality straight off the raw persisted settings object. On any save written before that field existed it is undefined, so .toUpperCase() threw and took down the entire render loop the moment you opened OPTIONS. The settings backfill is shallow, same trap as showFps and cursor before it. Reading through getGraphicsQuality(), which falls back to 'high', fixes it. Every new persisted setting is a new way to break an old save.
Next: the boss that farms your kills.