2026-09-06 · AFTERSHOCK

A boss in the wrong body, and a port checked byte for byte

Four reported bugs, three fixed and one that could not be reproduced but can no longer hang. Then the game began its port to Unity, starting with the rasteriser, because the whole game is drawn in code and the port is only right if the pixels are.

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.

beforeafter
crushers over a floor with a clear stroke74 of 8785 of 87
stage seven0 of 1313 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 framef+0f+1f+2f+3
before0.700.370.160.01
after1.000.570.290.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 run5.48s (unchanged)
death clock frozen6.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.

suitecomparesresult
rasteriser6 draw programs, half-pixel and out-of-bounds coordinatesidentical
rig64 figures: 8 operatives x 4 idle frames x 2 scalesidentical
pose147 poses, joint for joint at 1e-9, all 20 cyclesidentical

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.