Ten Thousand Moves
A thirty-minute measurement probe — play the live 2048 headlessly and watch the memory — turned into the part's detective story. The memory question never got asked before a worse discovery arrived: 9,746 of 10,000 keypresses were dying silently in production, swallowed by an error handler nobody remembered. The trail led through a bounds check to the third backend caught evaluating both sides of &&, and ended with the number the probe originally came for: the game had a 64-megabyte lifespan.
The second probe had a modest plan: the Wasm backend still used page-lifetime allocation — nothing is ever freed — and the live 2048 was the longest-running Wasm program the project had. Play it headlessly for ten thousand moves, watch the linear memory, and turn “the browser backend probably leaks per move” into a number. The harness was small: a Node script, a mock document, the real DOM glue, synthetic arrow-key events. The first run crashed on the first key. Whatever the memory answer was going to be, something else was wrong — and it was wrong in production, on the site, right then.
The failure nobody could see
The first lesson came from the harness itself: the probe’s stub for the print import silently swallowed the language’s failure messages, which cost an hour of blind debugging — implement the failure path first, even in a throwaway harness. With messages flowing, the picture sharpened into something worse than a crash: the game’s key handler was trapping, the DOM glue was catching the trap, logging it to a console nobody watches, and carrying on. Under that arrangement the game doesn’t die — it just stops responding to most input while looking alive. Counting instead of skimming gave the headline: 9,746 of 10,000 keypresses trapped. The first three moves worked; nearly everything after died. The live game, played by anyone past a few moves, had been broken since the day it shipped — and the launch-day verification, a few headless keypresses, had tested exactly the moves that work. Dogfood, it turns out, is not something you verify at launch; it is something you keep running.
The third backend
Symbolicated stack traces (one build flag) pointed into the game’s stuck-detection — the
routine that scans the board asking “is any move still possible?” Its inner test is the
most ordinary idiom in programming: r < 3 && bget b (i + 4) == v — guard the index,
then read. The guard was working; the read was happening anyway. The interpreter
short-circuits &&. The C backend inherits short-circuiting from C itself. The Wasm
backend evaluated both sides — its && compiled to a strict bitwise instruction — and
the LLVM backend turned out to do the same, behind a comment claiming eager evaluation
was observationally equivalent because “the MVP subset has no effects.” That comment had
been true once. A trapping array read is an effect. The fix made both backends lower
&& and || to their existing if/else emission, and a bounds-guarded read is now safe
everywhere — the third and fourth backends to learn what short-circuit means, and a
reminder that semantic claims in comments age worse than code.
The number the probe came for
With the game actually playable, the original question got its answer, and it was pleasingly exact. The remaining traps all started at move 7,764, and the allocator’s bump pointer stood at precisely the module’s 64-megabyte memory ceiling: the game burns about 8.4 kilobytes of never-reclaimed memory per move — board snapshots, render strings — and dies when the page-lifetime model runs out of pages. A determined player kills the tab in under an hour. That is not a bug in the game; it is the honest cost of a backend without reclamation, now measured to three significant figures. The measurement did what forcing numbers do: it moved “port the memory-model work to Wasm” from a someday-item to the top of the queue, with its acceptance test already written — this same probe, expecting a flat line. The next episode runs it.