A blocking Rust game loop in the browser, unchanged

How a 20,000-line terminal roguelike runs on a static web page without restructuring its engine.

Hollowmoon is a classic roguelike in the ADOM tradition: an 80×25 text screen, the full classic keyset, eighteen races, a village with quests, a wilderness and a dungeon fifty levels deep. It was written as an ordinary Rust terminal program, and the heart of it looks like every terminal game ever written:

loop {
    ui.render(&mut game);
    let key = ui.read_key();          // blocks until a key is pressed
    match game.keymap.feed(key) {
        KeyFeed::Command(cmd) => game.execute(cmd, &mut ui),
        ...
    }
}

Browsers do not work like that. A page cannot block waiting for input; it returns to the event loop and is called back when a key arrives. The usual answer is to invert the game into a state machine that is handed one event at a time. For a roguelike that is a large rewrite, because read_key is not only called from the main loop. It is called from deep inside the game: a quest-giver asks "Accept the quest?" in the middle of a conversation, throwing asks for a direction, the inventory asks which item, a new trait is picked from a menu. Every one of those is a blocking call several frames down the stack, in the middle of game logic.

Hollowmoon keeps all of them. The same engine, with no browser-specific code in it, runs in a terminal and on this site.

Asyncify: pausing a WebAssembly stack

Binaryen's wasm-opt has a pass called Asyncify. It rewrites a WebAssembly module so that its whole call stack can be saved to memory ("unwound") and later restored ("rewound"). You tell it which imported functions may pause, and it instruments every function that can reach them.

The game is compiled for wasm32-unknown-unknown with no wasm-bindgen. The terminal backend's read_key becomes an import, hm_read_key, and so does a hm_sleep used to animate bolts and thrown weapons. The build is two commands:

cargo rustc --lib --release --target wasm32-unknown-unknown --crate-type cdylib
wasm-opt target/wasm32-unknown-unknown/release/hollowmoon.wasm -O3 \
  --asyncify --pass-arg=asyncify-imports@env.hm_read_key,env.hm_sleep \
  -o dist/hollowmoon.wasm

On the JavaScript side, the import does one of two things depending on Asyncify's state. If the stack is being rewound, the game is resuming: stop the rewind and hand back the key. Otherwise, start an unwind and remember what we are waiting for:

function suspend(makePromise) {
  const ex = instance.exports;
  if (ex.asyncify_get_state() === 2) {        // rewinding: we are being resumed
    ex.asyncify_stop_rewind();
    return [true, resumeValue];
  }
  const header = new Int32Array(memory().buffer, asyncData, 2);
  header[0] = asyncData + 8;                  // where saved frames start
  header[1] = asyncData + ex.hm_asyncify_buffer_len();
  ex.asyncify_start_unwind(asyncData);
  waiting = makePromise();                    // e.g. "the next keypress"
  return [false, 0];
}

After an unwind, the game's entry point hm_main returns to JavaScript as though the game had ended. The page waits for the promise (a keypress, or a timer for an animation frame), calls asyncify_stop_unwind and asyncify_start_rewind, and calls hm_main again. Asyncify replays the stack back down to the exact call that paused, which now returns the key. The game never knew it stopped.

The host is small

Everything else crosses the boundary as plain imports from an env module, with strings as pointer and length pairs in linear memory: hm_present hands over the 80×25 grid of character and colour cells for a canvas to draw; storage imports keep saves in localStorage; one more fills getrandom's buffer from crypto.getRandomValues. The native build implements the same small storage interface with files, so the platform checks stay at the edges: seven files out of about 20,000 lines of Rust mention the target at all.

What it costs

BuildSize
wasm-opt -O31.95 MB
wasm-opt -O3 with Asyncify2.78 MB (+42%)
…as served, gzipped0.96 MB

Asyncify adds code to every function that might be on the stack when the game pauses, which for a game whose input is called from everywhere is most of it. A turn-based game spends its life waiting for a key, so the runtime overhead does not show; the download size is the real cost, and under a megabyte compressed is acceptable for a game.

Two traps

Alignment. Asyncify's data buffer starts with two 32-bit words (where the saved frames begin and end), and the page writes them through an Int32Array, which must start on a multiple of four. A plain static mut BUF: [u8; N] has an alignment of one. It happened to land on a good address locally and on a bad one in CI, which failed with "start offset of Int32Array should be a multiple of 4". The fix is a wrapper type:

#[repr(C, align(16))]
struct AsyncifyBuf([u8; ASYNCIFY_BUF_LEN]);
static mut ASYNCIFY_BUF: AsyncifyBuf = AsyncifyBuf([0; ASYNCIFY_BUF_LEN]);

Randomness. getrandom has no source of entropy on wasm32-unknown-unknown. With its custom feature you register a function that calls an import, and the page fills the buffer.

Testing it without a browser

The same imports are about a hundred lines of Node. A headless runner loads the WebAssembly, feeds it scripted or seeded random keys, runs the unwind and rewind loop, and prints the final screen and every telemetry event. CI builds the site and plays three thousand random keys through it before deploying, and checks that the browser and terminal builds generate identical worlds for the daily challenge.

A bot that plays 18,900 games

Because the engine does not care what drives it, the native build has a hidden --simulate flag: a simple bot that explores, fights, drinks potions and dives, playing every one of the 378 race and profession pairs fifty times across all cores in about thirty seconds. It found real problems before any player did: spellcasters who could not afford their own spells, a level that walled off everyone but fighters, auto-explore ignoring closed doors. Duels staged in tests then separated "the game is too hard here" from "the bot plays badly here".

The game itself

Hollowmoon is about a cracked moon that is erasing the world, names first. Each character is the next numbered expedition into the dungeon; when one dies, the next finds their grave in the village and their remains where they fell. As the ninety-day clock runs out, the villagers start to forget their words, then each other.

Play Hollowmoon   or try today's Daily, a ten-minute descent everyone plays identically, built for phones.