Porting a fluid puzzle twice: once bit for bit, once in the editor
Undercurrent is a puzzle game I wrote as a standalone WebGPU app before Awaken could run it. It is played in a tank of water: you place fans, pipes, spinners and vents, press Play, and the water carries the cargo to a goal. Underneath is a Jos Stam stable-fluids solver in compute shaders, after Memo Akten's MSAFluid, with 30 red-black pressure passes a step and 40,000 particles drawn over the dye. There are 104 levels in 13 chapters.

Every level carries its own test data. A Solution layout must win within par, an Orb Solution must collect every spark, and a handful of naive and generic layouts (a fan aimed straight at the goal, a fan in a corner) must fail. A headless checker plays all of them.
That data is what makes a port hard, because the flow is chaotic. While I was building the original, taking one 0.012 disc out of the water's distance field when a spark was collected broke 18 of the 104 levels. Any change to the solver, its precision or its step order is a change to the levels.
So I ported it twice, and planned it that way: first an exact copy to measure against, then the version built the way an Awaken game should be.
Port one: the same bits
The first port was the reference, never the one I meant to ship. It kept the WGSL kernels and their parameters verbatim and moved only the host code onto Awaken. Tests pin every copied file by the first 16 hex digits of its sha256, so a kernel cannot be tidied up without a test failing.
Awaken had no way to run content compute, so this needed a new engine seam, api.gpu. A script declares named textures and buffers, dispatches its own WGSL kernels and reads results back asynchronously, all on the renderer's device. The renderer's only contact with those resources is a material sampling one by name, the same way it samples an imported texture. Anything WebGPU would reject, such as an unknown name or an unaligned write, is dropped at the call and logged once, so a mistake costs its own dispatch and not the frame.
Parity was measured with a field probe. It runs the same emitter for K solver steps from still water in both games, on the same GPU in headless Chrome, then compares the solver's uniform floats as bit patterns, every f16 half of the 410 x 256 velocity field and every channel of the 1280 x 800 dye. At 1, 7, 120 and 600 steps, all 104,960 velocity texels and all 1,024,000 dye texels were identical.
A comparison that has only ever passed proves very little, so the probe has controls. Changing the Awaken emitter's radius from 0.028 to 0.0281 left 2 of the 104,960 velocity texels identical after 120 steps. Running 121 steps against the original's 120 left none.
Then the level checker ran in both games side by side, comparing every attempt's outcome, time, step count and sparks. The final run had 104 of 104 attempt lists identical and 751 of 751 trials identical.

The difference was in Math.atan2
Getting there took one detour. The first side-by-side run of chapter 11 reported that the attempt lists for 11-8 differed, even though all 55 trials in the chapter had the same outcome. One fan's aimed angle was 0.18822150530477064 in Awaken and 0.18822150530477066 in the original.
The port's build step evaluated the level files in Node 22 (V8 12.4) and shipped the results to the game as data. The original evaluates them in the page. The levels aim their fans with Math.atan2, and for these inputs Chrome 156 returns one unit in the last place more than that Node does. The ECMAScript spec leaves the exact result of Math.atan2 to the implementation, so neither is wrong. Across all 104 levels, 17 numbers differed this way, all in the tools of 11 levels and none in the walls.
In a chaotic simulation one unit in the last place of a fan angle eventually becomes a different run, so the fix was to stop precomputing. The level modules now run in the page, in the same JavaScript engine the original uses.
A second offset showed up the same week. Both games have to start a level from the same world clock. When the probe loaded 11-6, Awaken's boot level had already stepped twice, so its moving slider began 0.016 away from the original's, and the solution collected 1 spark of 2 in Awaken against 2 of 2 in the original. The probe and the checker now send both games back to the build phase before loading a level.
Port two: built in the editor
Bit parity meant keeping the original's code, and the project looked like it: 3 scenes, one game script of 11,236 lines assembled from the original's TypeScript at build time, and 13 compute shaders with no editor panel. The levels were code. That made it an exact thing to measure against, and it is not how an Awaken game is meant to be made. In the editor you can press Play on it, and there is nothing else to open.

The second port started from one rule: every part of the game is something the editor shows and edits, and every engine feature the game needs has a use outside fluids. Bit parity was a non-goal this time, and the code port stayed as the reference.
What the engine gained
The second version needed these, roughly in order of how much they mattered:
- Render textures. An asset with a size, a format and a live thumbnail, allocated the first time something references it.
- Cameras with a target texture and render layers, so one camera draws only the walls into a texture and another draws only the tools' force brushes into a different one.
- Update shader graphs. A graph whose output is written over a whole render texture each pass, with nodes such as Load Texel, Previous and Loop. Advection, curl, vorticity, divergence, pressure and the gradient are now graphs you can open and read.
- Simulations. An ordered list of update, camera, clear and copy steps, run on a new fixed clock: 60 Hz, at most 5 steps a frame. A frame that owes more slows the game instead of skipping steps, so an outcome never depends on frame rate. Game of Life and Ripples sit in the Create menu as the non-fluid examples.
- Texture probes, so a script can read the water under the cargo. Results arrive 2 steps (33 ms) late, so a step does not wait on its own readback. A ball follows the water with a time constant of about 143 ms, so the lag is 23% of it.
- Shape 2D, 2D physics and linked prefabs for the level pieces.

The game now opens as 105 scenes (Home and one per level), 28 prefabs, 39 shader graphs and 26 scripts totalling about 2,000 lines, with no compute shaders at all. A level's winning and failing layouts are inactive groups in its own scene, so you can switch one on and look at it.


Earning the levels back
Without bit parity, the bar became the original's own checker criteria, played through the editor's Play on each level scene.
As imported, 84 of 104 levels passed. Two fixes to the port, applied to every level, brought that to 90: the tank's edges now behave as the original's do (divergence mirrored at the grid edge, pressure and gradient clamped there), and ink wells pour their own colour. Retunes brought it to 102. Eleven levels each had one tool in one layout nudged, most by a degree or by 0.01 to 0.02 of the tank's height.
The last two needed the level itself changed. In 7-8 a generic layout with a fan ahead of a glass ball won in 1.2 s. In the original the same layout shatters the ball against the top wall: there the ball stalls under the fan for half a second before it rises, and here it rises at once and the goal catches it on the way. A balance that fine moves with the probe's two-step lag. The goal moved 0.02 to the right.
In 13-6 a naive layout dropped the weight on the right, and the water along the floor carried it left onto the pad that opens the door. Twelve nudges of the pad changed nothing, so the floor gained a small post beside it. All 104 levels now pass.
Engine bugs the checker would not let me tune around
The first run after opening a project behaved differently from every later run. A Simulation's camera step drew with the fallback material while the brush graph's pipeline was still compiling, so that first run pushed different forces into the water. Camera steps now wait for their materials, as update steps already did.
The frame that starts Play ran its fixed steps before the scripts existed, so how many steps passed before the first onFixedUpdate depended on how long that frame took. That frame now runs no fixed steps.
A slow frame owes several fixed steps, and every camera step inside it drew the moving walls where the last step had left them. 11-6 and 12-3, both with moving walls, changed from run to run. For a while I had the checker cap Play at one fixed step a frame to hide it, which amounts to testing on a device that is never slow. The fix keeps each step's transforms and copies them into the model buffer before that step's passes. The checker dropped the cap afterwards and runs on the project's real time settings; the last full run averaged 1.09 fixed steps a frame, with a maximum of 3.
The particles found one more. Asking for an rgba32float render texture, before that format existed, silently produced rgba8unorm. Particle positions then moved in steps of 1/255 of the tank, and a speck in slow water never moved at all. The format exists now, and the editor refuses an unknown format with a list of the ones it supports.
What the checker said about phones
The checker also settled a performance question. Quality levels can now lower a render texture's size, a Simulation step's pass count and the render scale per device. On a copy of the project, a 0.75 simulation resolution with 20 pressure passes instead of 30 made 3 of 12 sampled levels fail. Lowering only the look (dye, particles, render scale) passed every sampled level. So phones will get look-only quality levels first, and any level that touches the simulation has to pass the checker before it ships.
Two kinds of "the same"
Bit parity was worth having while it lasted. It found the atan2 difference and the clock offset, neither of which shows up in a picture. It cannot survive a change of representation, though, and moving the game into the editor changed almost every representation it had: compute kernels became fragment passes, the dye's resolution changed, and the cargo moved to the CPU and reads the water two steps late.
What carried over was the checker. Each level states which layouts must win and which must fail, and any version of the solver can be held to that.