The Bug Was Never in the Browser
The fourth tool was a browser game — 2048, drawn as text and steered by the arrow keys, meant to be a live demo people could actually play. It added the one frontend capability the bindings lacked: keyboard input. But then the board started scrambling, and chasing that scramble led somewhere no one expected — not into the browser, not even into one backend, but into a bug in how the compiler had been lifting nested functions all along, triggered by the most ordinary habit in programming: naming your loop `go`.
The fourth tool left the command line entirely. It was 2048 — the little four-by-four sliding-tile game — running in a web browser, its board drawn as plain text and steered with the arrow keys. Unlike the first three, which each forced a native capability, this one had a second job: to be a demo someone could actually play by following a link, a far more convincing entry to the language’s playground than a counter or an echo. It reused the existing frontend bindings that compile the language to WebAssembly and wire it to the page, and it needed exactly one thing those bindings did not yet have — a way to hear the keyboard.
A capability that grew without touching the compiler
The one gap was keyboard input: the bindings could hear a click but not a keypress, and a key carries data a click does not — which key. Adding it meant a new binding that listens for key presses on the page and hands the key’s name to a callback written in the language. Passing a string into a language closure from the outside was a path the bindings had never exercised before, and building it was the whole of the milestone. What was quietly remarkable is where the change lived: entirely in the bindings layer, with no change to the compiler at all, and therefore no new release of the language. That is itself a finding — it says the frontend interface is layered cleanly enough that it can grow new powers without disturbing the core. To keep the game’s demands to that single gap, the random new tiles were generated by a tiny pseudo-random generator written in plain language code, seeded by the move count, rather than reaching for a builtin that might not exist on this backend. One gap, named and filled; everything else routed around.
The board began to scramble
With input working and the sliding logic proven correct in the interpreter — two and two merge into four, tiles compress toward the wall — the game was compiled to WebAssembly and run in the browser, and the board came out wrong. Tiles landed in places the rules forbade; a row that should read one blank blank two came out as two blank blank one. The logic was provably right — it passed in the interpreter, move for move — so suspicion fell on the new and unfamiliar parts: the browser, the WebAssembly backend, the interaction between allocating memory and capturing variables in a closure. The first guess was that this was a WebAssembly-specific fault, something about how that backend handled a nested function that allocates a list while capturing the board. That guess felt reasonable. It was also wrong, and the only way to find out was to stop guessing and reduce the failure to its smallest form.
Bisecting down to the real cause
Stripped of the browser, the game, and the graphics, the bug reduced to something startling
in its plainness. Take one function containing two branches of an if, and give each branch
its own little recursive helper — and, following the most natural habit in the world, name
both helpers go. Call them through a closure, and the program runs the wrong one: both
branches resolve to the second go. And this was not a WebAssembly quirk at all — the
native C build got it wrong in exactly the same way, while only the interpreter stayed
correct. It was a cross-backend correctness bug, present in both compiled backends, that had
been sitting in the compiler the entire time. The cause was in how nested functions are
lifted to the top level: each is registered in a lookup table keyed by its source name, so
a second go in the same function overwrites the first, and every call site that wrote go
resolves to whichever one was registered last. The lifted functions themselves were given
distinct internal names; it was the call-site lookup, still keyed by the original name, that
collided. The game’s board scrambled because the move logic for left/right and for up/down
each contained an inner loop, and — of course — each of those loops was named go.
Why an ordinary name was the perfect trap
This is the kind of bug that hides for exactly as long as no one writes the triggering
shape, and the triggering shape is almost aggressively common. Naming a recursive helper
go or loop is not exotic; it is what one does without thinking. Two of them in one
function, reached through a closure, is uncommon enough that no earlier program had hit it,
and ordinary enough that it was only a matter of time. It was a close relative of an
earlier bug where two same-named helpers in different functions collided — this was the
same collision within a single function — but the fix was not a simple copy of the older
one. The right repair was a single pass over the program, run before either backend sees it,
that renames nested functions only when their names actually collide: the first keeps its
name, and each later reuse is made unique. Because it runs before the split into backends,
one pass fixes both the native and the WebAssembly output at once, and because it only
touches genuine collisions, every program that never had one is left byte-for-byte
unchanged — including all the tests that assert on specific function names.
The game shipped after that. It is live and playable in a browser, drawn as text, steered by
the arrow keys, spawning tiles from its little generator. The stated goal — a reachable demo
someone could actually play — was met. But the deeper result was the detective story: the
tool built to test the frontend found, through a scrambled board, a bug that had nothing to
do with the frontend, lurking in the compiler across both compiled backends and waiting for
someone to write two loops named go. The three native tools before it had hardened the C
backend; this one hardened the WebAssembly backend — and then handed over something better
than a backend fix, a correctness fix that protects every program that names its loops the
way everyone names them. That was the last of the four tools, and the part closes next on
the strangest finding of all: a fix to the language that quietly broke a program written in
it.