A blocking Rust game loop in the browser, unchanged
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
| Build | Size |
|---|---|
| wasm-opt -O3 | 1.95 MB |
| wasm-opt -O3 with Asyncify | 2.78 MB (+42%) |
| …as served, gzipped | 0.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.