The morning was four bugs reported against the JavaScript game. The rest of the day was the start of a port to Unity.
Four reported bugs
A boss was wearing another boss's body
Reported as one late boss being the same as the one before it, and it was, literally. world.bossArt is baked once, from stage.bossId, on the frame the stage boss spawns. That stage's bossId is the earlier boss, so that is the atlas sitting there when the later one arrives, and the renderer draws whatever is in it.
The line meant to fix this already existed: w.bossAtlasId was written at the launch site and read nowhere. The intent was right and the wiring was never finished.
Nothing caught it because nothing crashed. Both atlases carry four phases and a ten-frame death clip, and every clip lookup is modulo-indexed, so the wrong machine drew perfectly happily. Rendering a dead boss through the wrong atlas draws 480 frames without throwing. Only a picture was wrong, so a picture is what the probe asserts now.
The pressure plates were under the floor
Reported as spiked plates pushing down from underground, with a request to check the other levels. A new probe answered both. In stage seven, all thirteen had their whole stroke inside solid rock, hanging below the walkway. Every other stage was clean, which is what marks it as an authoring fault and not a change in the machine.
The largest group was a convention error: pieces authored in world rows, then lifted by the stage builder's sky rows on top, landing ten rows low. Four more in two other levels had the opposite fault and stroked into open air.
| before | after | |
|---|---|---|
| crushers over a floor with a clear stroke | 74 of 87 | 85 of 87 |
| stage seven | 0 of 13 | 13 of 13 |
The probe tests shape, calibrated against the first level, which is locked and known good by play. A head that clips a single row of ledge on the way down is fine. A head that spends most of its stroke in rock, or stops over nothing, is not.
The roof did not come off
Reported as the ending explosion not bursting through the roof, and looking abrupt and broken. The idea was there and the timing was not. The biome and backdrop are swapped on one frame, and the only thing covering that swap was a 0.7 screen flash, gone in about two frames, so the old room was still plainly on screen when the sky replaced it.
| flash at the swap frame | f+0 | f+1 | f+2 | f+3 |
|---|---|---|---|---|
| before | 0.70 | 0.37 | 0.16 | 0.01 |
| after | 1.00 | 0.57 | 0.29 | 0.10 |
The impact is staged around that: a camera punch, a 0.09s stop (deliberately short of the 0.15 threshold, which belongs to one detonation and nothing else), and the daylight arriving a beat after the blast so the cut reads as an opening and not a scene change.
The white screen: not reproduced, but it can no longer hang
Reported as the end screen turning white while the sound carries on, and never reaching the next screen. Everything the symptom pointed at was tested and behaves, and the leading theory, that the wrong atlas crashed the renderer, was disproved.
What is true is that the hold could hang. It waits on boss.deathTime, which advances on gameplay time inside the boss's own update, so anything that stops that clock stops the level ending for good, with the last frame on screen and the audio running underneath. The hold now also counts the escape's own clock.
| victory at | |
|---|---|
| healthy run | 5.48s (unchanged) |
| death clock frozen | 6.60s, instead of never |
A guard, not a fix. If it ever fires, the reason it fired is a bug.
The Unity port
AFTERSHOCK now exists in Unity 6000.0.45f1, about 93,000 lines of C# across 100 files, from about 84,500 lines of JavaScript.
Why the rasteriser came first
The game has no art assets. Not few: none. Every sprite, tile, backdrop, boss, glyph and effect is drawn in code at boot, from a palette of 56 four-step ramps. So the port is not an asset migration, it is a port of a rasteriser. Port the gameplay first and you get a game that plays like AFTERSHOCK and does not look like it, with no way to tell which of a thousand draw calls went wrong.
Verified, not assumed
Reference dumps are generated from the running JavaScript and compared in C#, byte for byte. The check runs in about two seconds and has stayed green since.
| suite | compares | result |
|---|---|---|
| rasteriser | 6 draw programs, half-pixel and out-of-bounds coordinates | identical |
| rig | 64 figures: 8 operatives x 4 idle frames x 2 scales | identical |
| pose | 147 poses, joint for joint at 1e-9, all 20 cycles | identical |
Poses are compared by joint on purpose: thirteen coordinates say which joint is wrong, where a pixel diff only says the drawing changed.
Two faults only parity could have caught
Neither was a typo. Both were the port doing arithmetic differently from the thing it copies.
Float is not double. The first pass used float. 51 of 64 figures came out identical and the other 13 each differed by exactly one byte: always a pixel whose coordinate sat within a float epsilon of a rounding boundary and fell the other way. JavaScript has one number type and it is a double, so the pipeline now carries its own vector type and narrows once, at the pixel.
**Math.hypot is not sqrt(xx + yy).** V8 scales by the larger term and sums with compensation, so the two disagree in the last bit. The limb routine takes ceil(len * 2) as its sample count, so one ulp changed how many circles were stamped along one bone and put a single pixel of BULLSEYE one shade band out.
What was generated rather than ported
The palette, the roster, the weapon table, the font, and all seven levels. The stage files are 13,200 lines of which almost none is behaviour, and every builder is deterministic, so running them once in JavaScript and shipping the output is exactly equivalent to porting them, with none of the transcription risk: 1,042k tiles in 1.22MB. Levels are still edited in the JavaScript, where the probes live.
Reading the generated data turned up two gaps. The start tile was not carried, and the first level's value happens to be the engine default, so a silent fallback would have looked correct on level one and put the other six on the wrong row. And array-valued fields were dropped, including the keys list on five doors. Because every() over an empty list is true, a barrier gating the back half of five levels would have stood open from frame one. Both are closed and asserted.
It also turned up a real bug in the shipping JavaScript. One biome hands range() an already-composed rgba string, so parseInt of "gba(168,..." is NaN, and every mountain range there ships solid black instead of pale cyan. The port reproduces it rather than repairing it, since fixing it would blow the biome's brightness cap.
It runs
The first level loads, the player runs and shoots, enemies wake and fight back, the camera follows. Run headless for 240 frames, the player moved 506px, fired 24 shots and finished on 134 of 140 health. That last number is the interesting one, because nobody scripted the player taking damage.
