rust-sdl2-emscripten

A Rust + SDL2 demo cross-compiled to WebAssembly with Emscripten, sharing one blocking game loop across native and browser through Asyncify.

Demo Active Updated 3mo ago

A Rust program built on SDL2, compiled to run both as a native desktop window and — unmodified — in the browser through Emscripten and the wasm32-unknown-emscripten target. What renders is deliberately small: a sprite you nudge around a 640×480 canvas with the arrow keys, a mouse-tracking crosshair, an FPS readout, and a looping music track. The point was never the demo — it was to find out whether the Rust, SDL2, and WebAssembly toolchain holds together at all, and where it doesn't.

The one-loop problem

A native game loop blocks: while running { poll_events(); update(); render(); sleep() }. The browser will not tolerate that. JavaScript owns a single event loop, and a WebAssembly call that blocks to sleep never returns control — the tab freezes. So the one piece of structure every game shares does not survive the move to the web intact.

Emscripten offers two escapes. emscripten_set_main_loop inverts control: you surrender your loop and hand Emscripten a per-frame callback. The cost is lifetimes — the callback crosses into C as a bare function pointer, so the game state it touches has to be coerced to 'static, which the borrow checker resists. The other escape is Asyncify, which rewrites the compiled WebAssembly so a blocking call can unwind the stack, yield to the browser, and resume exactly where it paused once the timer fires. Under Asyncify the original while state.running loop compiles and runs untouched. The native build sleeps through std::thread::sleep; the web build sleeps through emscripten_sleep; the lines between them are identical. One loop shape, two platforms — at the price of a larger binary and Emscripten rewriting your control flow behind your back.

Platform seams

Everything that cannot be written once lives behind a #[cfg(target_os = "emscripten")] fence. now() reads emscripten_get_now() on the web and SystemTime natively. sleep() splits the same way. Font handling is stranger: the SDL2_ttf context has to outlive the main loop, and on Emscripten the cleanest way to promise that is to Box::leak it into a genuine 'static reference rather than thread it through the borrow checker. The seams are few, but they are load-bearing — get one wrong and only one of the two targets breaks.

State that survives a refresh

The demo remembers one thing: the music volume, adjusted with Shift+Up and Shift+Down across a 0–128 range. Native builds write it to a volume.txt beside the binary. The browser has no such file, so the web build reaches the other way — it evaluates JavaScript from Rust. emscripten_run_script_string runs localStorage.getItem('volume') and hands the result back across the FFI boundary as a C string; setting it calls localStorage.setItem. It is a small bridge, but it is the whole shape of Rust-to-JavaScript interop in one function.

SDL2 without a system library

Natively, SDL2 and its four extensions — image, ttf, mixer, gfx — are system libraries you install and link. On the web there is nothing to install: they are Emscripten ports, compiled from source and switched on with linker flags. Assets travel the same path — --preload-file assets/ packs the sprite sheet, font, and music into a spiritus.data file the runtime fetches alongside the .wasm. A Bun script drives the build (pinned to emsdk 3.1.74, the first release where the SDL2 port's cache lock is fixed), runs the asset and CSS steps, and assembles the deployable dist/.

The full Emscripten flag set

Set in .cargo/config.toml for the wasm32-unknown-emscripten target. ALLOW_MEMORY_GROWTH is what lets SDL2 expand its heap at runtime; USE_OGG pairs with the mixer so the embedded music track decodes.

toml
[target.wasm32-unknown-emscripten]
rustflags = [
    "-C", "link-args=-sASYNCIFY -sALLOW_MEMORY_GROWTH=1",
    "-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_MIXER=2 -sUSE_OGG=1 -sUSE_SDL_GFX=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=['png']",
    "-C", "link-args=--preload-file assets/",
]

The honest verdict

The README does not oversell this. In its own words the project is "still quite hacky," the documentation and examples are sparse, and the tooling is far less mature than what other languages bring to the web. Its own advice to anyone starting out is blunt: reconsider whether you need web builds at all, go web-only, or pick a different stack — native C++ with SDL2 is steadier ground. And it names the real risk directly: for a larger project, the complexity grows exponentially, with debugging time outrunning development time.

The sequel

That larger project got built anyway. Pac-Man is a full arcade clone on this exact foundation — Rust, SDL2, and Emscripten to WebAssembly — with Bevy ECS layered on top. This demo was the spike that came first: where the toolchain got proven, the seams got mapped, and the exponential-complexity worry got tested against something real before a whole game depended on it.

§07

Gallery

Related work

On this page