← Back to projects
Playable · Chrome

JA2 · Web

Jagged Alliance 2, the Stracciatella engine, cross-compiled to WebAssembly, Rust core and all.

  • 2languages in one WASM binary (Rust + C++)
  • 8 MBstack, up from a 64 KB default
  • 4 GBmax heap, 8-thread pool
  • 0.22.1Stracciatella, full engine
Screenshot 1 Jagged Alliance 2 booting to its main menu in a browser tab at ja2.virtastic.app, on the real Stracciatella engine.
Screenshot 2 An isometric tactical sector with a merc deployed: the C++ engine and its Rust core running as one WebAssembly binary.
Screenshot 3 The in-game laptop, day one: hiring mercs and managing finances through the strategy layer, all client-side.

JA2 · Web is the sibling to my OpenMW port, and in one way it is harder. Jagged Alliance 2's modern engine, Stracciatella, is not one language but two: a C++ game engine sitting on a Rust core. Running it in a browser meant compiling and linking both into a single WebAssembly binary, then teaching a turn-based tactics game from 1999 to live inside an event loop.

The whole game, C++ and Rust, in a browser tab

This is the real Jagged Alliance 2, the open-source Stracciatella engine at version 0.22.1, cross-compiled to WebAssembly with Emscripten 6.0.1 and run in the browser off the original game data. It boots to the main menu, takes mouse and keyboard, plays sound through Web Audio, keeps its saves in the browser, and reaches live turn-based tactical combat. It is the whole engine, backend, UI, and game logic, not a shim or a screenshot. What sets it apart from a typical WebAssembly game port is the source: Stracciatella is a C++ engine built on a Rust core, so this is really two ports that have to become one binary.

Two languages, one WebAssembly binary

Stracciatella keeps its core, configuration, virtual filesystem, and data handling in a Rust library and exposes it to the C++ engine over a C ABI generated by cbindgen. To run in the browser, both halves are cross-compiled to the wasm32-unknown-emscripten target and linked into a single module. The Rust crate builds to a static library and a generated header; CMake drives cargo, captures the archive, and hands it to the Emscripten linker alongside the C++ objects. The two languages then share one heap through a single mimalloc allocator and one pthread pool. The last trap is exceptions: C++ exceptions and Rust stack unwinding cannot safely cross the WebAssembly boundary, so Rust is built panic-abort and both sides use JavaScript exceptions, and a Rust panic terminates its thread cleanly rather than unwinding into C++.

Rebuilding the Rust standard library

Threads are where the Rust side gets genuinely hard. Emscripten ships a prebuilt Rust standard library, but it is compiled without atomics or bulk-memory, and a library without those cannot join a shared-memory, -pthread link. So the standard library is rebuilt from source at compile time, on a pinned nightly toolchain, with -Zbuild-std and the +atomics, +bulk-memory, and +mutable-globals target features turned on. Underneath that sat a nastier problem: a 2024 nightly silently miscompiled std::path on this target, writing NUL bytes into paths so that every resolved file collapsed to the root and the virtual filesystem failed to initialize. The toolchain is pinned to a known-good nightly, and the path-resolution code is rewritten to avoid the bad code path entirely.

The Emscripten link

The final link is a hand-written script. The heap starts at 512 MB and can grow to 4 GB, enough to hold the game data in memory. Threads come from -pthread with a pool of eight. The runtime is kept alive after main() returns so the browser can pump frames, the filesystem layer is forced on, and IndexedDB is linked in for saves. Exceptions are the JavaScript kind, link-time optimization is off because it miscompiles a binary this size, and SDL2 comes from the Emscripten port rather than a system package. The most consequential single number, though, is the stack.

The stack that was 64 KB

Emscripten gives a WebAssembly module a 64 KB stack by default. Jagged Alliance 2 does not fit in 64 KB. Loading a tactical sector runs the world loader deep enough to overflow the stack, and because a wasm stack overflow does not fault cleanly, it quietly corrupted adjacent linear memory. The corruption then surfaced far from its cause: as a mimalloc atomic-alignment trap, as an out-of-bounds error inside a different allocator, as random renderer crashes, none of which pointed anywhere near the stack. Once the real cause was found, the fix is a desktop-sized 8 MB stack with an overflow-check guard.

A frame pump, and the loops that would freeze the tab

A native game owns the main loop and blocks on it; a browser tab cannot. So the engine main loop was turned inside out into an exported pump function the browser calls once per animation frame. It polls SDL events, runs a game cycle throttled to 144 Hz, and returns immediately, never sleeping on the browser thread. That exposed a second problem: several screen transitions animate a zoom by busy-waiting on the real-time clock in a tight while loop, refreshing the screen but never returning to the event loop. On the desktop that blocks for a second or two; in a browser it freezes the tab, and the canvas cannot even repaint mid-loop, so the animation is invisible anyway. All five, sector load, pre-battle, auto-resolve, and the laptop opening and closing, are cut on the web. Tactical combat itself was already event-driven, so gameplay is untouched.

A 2D isometric game on WebGL

Jagged Alliance 2 is a 2D isometric game that renders in software to a framebuffer, so on the web that framebuffer is blitted to a WebGL texture and presented. The trick is presentation: tactical combat is shown at the full window resolution, so you see more of the battlefield crisply, while the fixed-layout screens, the main menu, the laptop, the strategic map, are drawn at their original 640 by 480 and scaled up to fit with CSS. Mouse input needs no engine change; SDL2 maps browser coordinates back through the same scale, so clicks land on the exact framebuffer pixels the game expects.

Sound without a sound card

Audio runs through SDL2, whose Emscripten backend routes to the browser Web Audio API, with miniaudio compiled as a pure codec and resampler with its device I/O stripped out. Two web-specific quirks had to be handled. Web Audio forces its own sample rate, usually 48 kHz, regardless of the 44.1 kHz the game asks for, so feeding 44.1 kHz samples to a 48 kHz device played everything about nine percent sharp until the code reads back the real device rate and converts to it. And because the audio callback runs on the busy main thread, the buffer was deepened from 1024 to 4096 samples to hide frame jank, and streaming decode was disabled so audio never contends with the game for the Rust filesystem.

Game data, and where the saves live

The game data lives in Stracciatella SLF archives, the commercial content that was never open-sourced, so nothing copyrighted ships in the build; you bring your own from a legal copy. In the browser the archives are mounted read-only in memory and packaged separately from the engine, so a rebuild relinks in seconds instead of repackaging hundreds of megabytes. Saves and configuration live somewhere persistent instead: an IndexedDB-backed filesystem mounted at a user-data path, flushed to the database every fifteen seconds and on tab hide, so a campaign survives a reload. The launcher that picks data and mods is a plain HTML page that talks to the same IndexedDB config directly, so it needs no WebAssembly at all.

Shipping it

Like the OpenMW port, the whole thing depends on SharedArrayBuffer for its threads, which means the page must be cross-origin isolated with COOP and COEP headers on a secure origin; the dev server and the production nginx config set them on every response, serve Brotli-precompressed artifacts, and cache the immutable wasm and data for a year. Under it all sits a real dependency stack cross-compiled to WebAssembly: SDL2, Lua with its C++ bindings for scripting, miniaudio, and a dozen Rust crates from serde to rayon, every native piece rebuilt for the web.

Stack

  • C++17
  • Rust (nightly)
  • Emscripten 6.0.1
  • SDL2
  • WebAssembly
  • pthreads
  • SharedArrayBuffer
  • Lua 5.1
  • cbindgen
  • IndexedDB
  • miniaudio