Two sessions. The game got its soundtrack, a death sequence you can actually watch finish, a credits roll, and a roster screen that stopped being a picture somebody painted.
The soundtrack
The build shipped two mp3s and played one of them. startMusic() took no argument and called playMusic("level1"), so a campaign that runs seven biomes played the first one's theme over all of them.
There are nine tracks now — seven levels, the title, the credits — and startMusic(level, fade) picks by level number, which is what the world map, the save file and the attract loop already count in. Music is by Joel Lauver, from the album Nightcrawler.
The .gitignore was eating them
The rule for the source library was a bare music/, and that matches a directory of that name at any depth — so it was also ignoring assets/music/, the folder the deploy script actually ships.
The two tracks already in there survived only because git does not re-check files it is already tracking. Every track added after that rule would have been invisible to git status, committed by nobody, and 404 on the live site while working perfectly on this machine. Anchored to /music/.
Deploy went 155 files / 20MB to 163 files / 41MB, and all nine tracks were checked live for audio/mpeg rather than the HTML fallback a missing file returns.
A death that finishes before the screen does
A boss death ran 2.6 seconds, with its white frame — the one moment the whole sequence is built around — at 2.55. The level cut to the stage-clear tally 2.4 seconds after the killing blow.
The detonation landed 0.15s after the screen it was drawn on had gone. Every single time.
It is staged now: tumble out to 2.4s, then a cascade at more than double the rate to 4.2s, the detonation, then aftershocks thinning to nothing at 7.2s. Measured across the sequence — 78 blasts, 31 in the tumble and 39 in the cascade, 12.9 per second rising to 21.7, loudest at 1.60.
What made it possible is that the level is held open for it. The tally cannot wait; the phase before it can, because the level is still playing in that phase, so the world keeps simulating and the camera keeps shaking underneath.
Explosions have been popping along the screen for that whole stretch and not one of them ever made a sound. They are audible now, and both their rate and their volume ride on the same quantity the danger rides on — so the audio is a readout, not an ambience.
One deliberate silence: when the biggest one goes off, the music cuts. The sidechain duck is built to push a score down under an explosion, and for the next half-minute the explosion never stops — what it would produce is a track pumping in and out of audibility.
Measured by probe and by driving the screens directly. The timings are right; how it feels at the end of a real seven-level run is not something a probe can tell you.
The credits roll
There were none. The campaign ended on the victory plate, and START reset the route and dropped you back into character select — the roster again, four seconds after the last machine in the game fell over.
It is an eighty-second roll on its own clock now, reached exactly one way.
Every list in it is read from the game rather than typed: the campaign from STAGES, the machines from BOSS_KINDS, the operatives from CHARACTERS. A hand-typed roll is a second source of truth for the roster, and the first time an eighth stage lands it becomes a lie nobody looks at often enough to catch.
The written in section is counted, not remembered: 90 .js files and 83,540 lines under src/, 82 of them carrying JSDoc types, over a 24-line index.html and a 35-line styles.css. The tooling is credited and explicitly marked as not shipping.
The roster screen was a painting
It was a 1672x941 plate — 2.27MB, the largest single file in the build — with all eight operatives already in it. The renderer's whole job was to draw coloured frames onto the right rectangles of a picture.
It is generated now: portraits cropped from each operative's own idle frame, over one of the real biomes, rolled per visit and drifting.
The fix was the ground, not the figure
The obvious diagnosis is resolution — the portrait is a 3x magnification of a sprite drawn for 1x. The obvious diagnosis was not the first one.
Between 15% and 23% of every drawn portrait is the 1px #07080c outline the rasteriser lays around the silhouette, and it was sitting on a plate at luma 7.7. That is a ratio of 1.03:1. The outline was reading as holes, not as a silhouette.
Lighting the ground behind the head makes that same untouched outline snap into a line. Each operative gets an authored key colour behind them, a vignette, a drop shadow, two directional rim crescents and the chassis bevel the palette has specified all along. The whole card is composed once per operative and blitted.
| before | after | |
|---|---|---|
| figure/ground contrast, mean | 4.60:1 | 6.88:1 |
| figure/ground contrast, worst | 3.21:1 | 5.24:1 |
| cell loop, in situ at 1920x1080 | 0.2 ms | 0.1 ms |
| painted plate in the build | 2.27 MB | none |
Framing, wrong by six rig units
The comment block claimed the crown sits at rig 41. Measured, it is 38.
That error is why every operative had 21 pixels of a 76-pixel cell — 28% — guaranteed empty above their head, while a 16px floor fade ate their shoulders three pixels below the shoulder line.
And a bug fixed on the way
The old code fell back to one particular operative's atlas for any un-warmed pick, so the first frames of the screen showed the wrong operative under the right name. Un-baked cells now say STANDBY in their own colour. Verified by locking the warm loop into a permanent cold start: eight distinct standby cards, no wrong faces.
Tried, and taken back out
A second pass went further: a dedicated portrait pose with the hands down, and the rig re-baked natively at 3x instead of magnified, with the rasteriser's shade bands widened to match. It worked, and it measured well — contours genuinely re-rasterised, the floating fist gone, the arrival hitch it introduced traced and fixed.
It was reverted on sight, because it read off brand. The roster is the version above, which is what is deployed. Recorded here because the measurements stand and the approach is on the shelf — not because it is coming back.
Smaller things
The arrow keys did nothing on the roster. Reported as "I can't select any player I want", and that is exactly what it was. Every other page reads the union of all devices; the roster reads per slot, and the arrows are player two's cluster. Slot zero now reads the union while it is the only seat that can be taken.
The theme starts on the logo. It used to start on the first keypress. Where the browser allows autoplay it comes up under the boot logo and is already going when the title lands — two seconds after a cold load, with no gesture, the track is 2.13s in.
A refused autoplay used to mean permanent silence. resume() resumed the AudioContext and never touched the <audio> element, so a rejected play() left the game believing it was playing its theme. Fixed, and both halves proven in the browser.
Four renames: BUZZARDSAW, REVAULATOR, THUNDERDOME, and GHOST PROTOCOL — the last of which was already that operative's title, so the title moved to SKIRMISHER. A name over a title in two colours saying the same thing reads as a rendering fault.
YOU DIED became YOU'RE DEAD.
