Concept · Platform

WebAssembly & the Browser Build

When you press "Launch" on this site, a full 3D engine — streaming terrain, procedural forests, a day/night cycle — starts running inside your browser tab, with no plugin and no server doing the work. The technology that makes that possible is WebAssembly. This page explains what it is, how Rust code becomes it, and how FloraForge's browser build actually boots.

A compile target, not a language

WebAssembly — usually shortened to wasm — is a compact binary instruction format that browsers can execute at close to native speed. The crucial thing to understand is that nobody writes WebAssembly the way people write JavaScript. It is a compile target: you write a program in Rust, C++, Go, or another compiled language, and the compiler emits a .wasm file instead of a Windows or macOS executable. The browser then validates that file and compiles it down to real machine code for whatever CPU it's running on — which is why wasm performance lands so close to a native binary's.

That design solves a problem JavaScript never could. JavaScript is a dynamically-typed language that the browser must parse, interpret, and optimise on the fly; an engine doing millions of floating-point operations per frame fights that model constantly. A wasm module arrives already compiled and statically typed, so the heavy numeric work — noise functions, mesh building, physics — runs at essentially the speed the CPU allows.

The sandbox: linear memory

A wasm module is also radically contained. All of its data lives in one linear memory — a single contiguous, growable array of bytes that the module reads and writes with plain offsets. It cannot reach outside that array: no pointers into the browser's memory, no system calls, no file system, no network. Anything the module wants from the outside world must come through functions the host page explicitly hands it.

Those hand-offs are where JS glue comes in. For Rust, the standard tool is wasm-bindgen: it scans the Rust code for exported functions, then generates a small JavaScript module that loads the .wasm file, wires up the imports it needs (DOM access, fetch, the WebGPU API), and exposes the Rust functions as ordinary JavaScript functions — marshalling strings and objects across the boundary in both directions. The page calls the glue; the glue calls the wasm.

Rust source the whole engine world_core · app world_runtime renderer_wgpu Compile cargo build --target wasm32-unknown-unknown then wasm-bindgen generates the JS glue web/pkg/ world_gen_bg.wasm the compiled engine world_gen.js the JS glue floraforge.eu Browser tab <canvas> drawn via WebGPU wasm runs the engine, JS glue bridges to the page and the GPU world_base.bin prebuilt world, ~31 MB fetched alongside the bundle, so the tab never regenerates the world from scratch happens once per release, in CI happens in your browser, every visit
From Rust source to a running tab. The compile step happens once per release; the browser downloads the resulting bundle, instantiates the wasm, and the engine takes over a canvas — fetching the prebuilt base world alongside.

How FloraForge boots in a tab

FloraForge's release pipeline compiles the entire engine — the same crates that make the desktop binary — with cargo build --release --target wasm32-unknown-unknown, then runs wasm-bindgen over the result to produce web/pkg/world_gen.js and the wasm binary beside it. The landing page's launch script is then disarmingly small:

web/index.html — the launch script (condensed)
const wasmEntryUrl = new URL("./pkg/world_gen.js", window.location.href);

async function ensureWasmModule() {
    if (wasmModule) return wasmModule;
    const module = await import(wasmEntryUrl.href);
    await module.default();   // download + instantiate the .wasm
    wasmModule = module;
    return module;
}

async function launchGame() {
    const module = await ensureWasmModule();
    await module.start_web_app();   // hand control to the Rust engine
}

Two lines do all the work. module.default() is the wasm-bindgen initialiser: it fetches the wasm bytes, has the browser compile them, and wires up the imports. start_web_app() is not a JavaScript function at all — it's a Rust function, exported across the boundary by the #[wasm_bindgen] attribute. Here is its real body, from the engine's crate root:

src/lib.rs — the wasm entry point (trimmed)
#[wasm_bindgen]
pub fn start_web_app() -> Result<(), JsValue> {
    let canvas = web_sys::window()
        .and_then(|w| w.document())
        .and_then(|d| d.get_element_by_id("world-gen-canvas"))
        .ok_or_else(|| JsValue::from_str("canvas element not found"))?;

    let event_loop = winit::event_loop::EventLoop::new()?;
    let window = WindowBuilder::new()
        .with_canvas(Some(canvas.unchecked_into()))
        .build(&event_loop)?;

    app::run_event_loop_web(window, event_loop);
    Ok(())
}

The hand-off is the <canvas> element: the page finds it in the DOM, the Rust side wraps it as its "window", and from that point the engine's normal game loop runs inside the tab, drawing through WebGPU exactly as it would on the desktop.

Loading a world without freezing the tab

One desktop luxury does not survive the trip: threads. FloraForge's browser build is single-threaded, and a wasm module shares the page's main thread — if the engine computes for three seconds straight, the tab is frozen for three seconds. Building a world from scratch is minutes of work, so the web build sidesteps it twice over.

First, it mostly doesn't generate the world at all. The release pipeline runs the procedural generator once in CI and deploys the result, world_base.bin (~31 MB), right next to the wasm bundle. The app starts downloading it the moment the page loads, before you even press Launch, and the request carries the world's generation key as a query string so a stale cached snapshot is never matched against a newer engine. Second, what work remains — decoding that snapshot and rebuilding tens of thousands of plants from it — is cooperative: it's split into small bounded steps, and the engine yields back to the browser between every step so the loading screen keeps animating. The driving type is a tiny state machine:

src/world_runtime/runtime.rs — one unit of cooperative world-building
pub enum BuildStep {
    /// Still working; `0.0..=1.0` is the fraction of the build completed.
    Working(f32),
    /// The runtime is ready.
    Done(Box<WorldRuntime>),
}

An async task calls WebWorldBuilder::step() in a loop: each Working result updates the progress bar and yields; the Done result is the finished world. Once it arrives, the normal chunk streaming takes over — itself adapted for the single thread: where the desktop build generates chunks on a worker pool, the web build's loader generates at most two chunks per frame on the main thread, spreading the cost the same cooperative way.

In the engine
The browser build is the same Rust code as the desktop build — not a port. A handful of cfg(target_arch = "wasm32") switches swap the edges: the windowing target becomes a canvas, file storage becomes browser storage, threads become cooperative steps, and the graphics backend becomes the browser's own WebGPU. Everything in between — terrain, biomes, rivers, plant growth, the renderer — is one codebase compiled twice.