featured image

Collapsing the Wavefunction into WebAssembly: Shipping Multiversal Consciousness to the Browser

This advanced tutorial walks you through every step of compiling a C++23/SDL3 game engine -- Multiversal Consciousness -- into WebAssembly using Emscripten, then deploying it as a static webpage anyone can play

Published

Mon Jun 01 2026

Technologies Used

C++ SDL3 WebAssembly'
Advanced 36 minutes

This advanced tutorial walks you through every step of compiling a C++23/SDL3 game engine — Multiversal Consciousness — into WebAssembly using Emscripten, then deploying it as a static webpage anyone can play. By the end, you will have a fully browser-playable build of a dual-reality puzzle-platformer, served from nothing more than a handful of static files.

Prerequisites: You should already be comfortable building the engine natively (see CLAUDE.md). You need a working C++23 toolchain, CMake 3.20+, and familiarity with SDL3. This guide focuses exclusively on the Emscripten cross-compilation pipeline.

graph LR
    A["C++ Source\n+ SDL3 Headers"] --> B["emcmake cmake\n+ emmake make"]
    B --> C[".wasm\nCompiled Binary"]
    B --> D[".js\nGlue Code"]
    B --> E[".data\nPreloaded Assets"]
    C --> F["Browser Runtime\n(WebAssembly VM)"]
    D --> F
    E --> F
    F --> G["Player sees\nthe game!"]

Installing the Emscripten Toolchain and Preparing the Build Environment

Before you can compile anything to WebAssembly, you need the Emscripten SDK (emsdk). Emscripten is a complete compiler toolchain that translates C/C++ into WebAssembly bytecode. It provides drop-in replacements for cmake and make — called emcmake and emmake — that redirect all compilation through its LLVM-to-Wasm backend.

Clone the SDK repository and install the latest release:

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest

After installation, you must source the environment script so that emcc, emcmake, and emmake are available in your current shell session:

source ./emsdk_env.sh

On Windows, run emsdk_env.bat instead, or use the Emscripten terminal that ships with the SDK installer.

You also need pre-built SDL3 libraries compiled for Emscripten. Unlike SDL2 (which Emscripten can provide as a port via -s USE_SDL=2), SDL3 is too new to be included in Emscripten’s built-in ports system. You must build SDL3, SDL3_ttf, and SDL3_image from source using emcmake cmake targeting the Emscripten platform, then install them to a known prefix directory. The Multiversal Consciousness CMakeLists.txt expects to find them at a path like C:/Users/thism/Emscripten/Release — you will adjust this to match wherever you installed your Emscripten-built SDL3 libraries.

Forking the Build System: CMake’s Emscripten Crossroads

The core challenge of an Emscripten build is that you are cross-compiling: the host machine (your development PC) is not the target platform (the browser’s WebAssembly VM). CMake needs to know this, and emcmake handles it by injecting Emscripten’s toolchain file automatically. But your CMakeLists.txt still needs conditional logic to handle the differences.

The first divergence point is SDL3 discovery. When building natively, CMake uses find_package to locate SDL3 through your system’s package registry. Under Emscripten, those system libraries do not exist — you must point CMake at your Emscripten-specific SDL3 build:

if(EMSCRIPTEN)
    set(SDL3_PATH "C:/Users/thism/Emscripten/Release")
    find_package(SDL3 REQUIRED PATHS "${SDL3_PATH}" NO_DEFAULT_PATH)
    find_package(SDL3_ttf REQUIRED PATHS "${SDL3_PATH}" NO_DEFAULT_PATH)
    find_package(SDL3_image REQUIRED PATHS "${SDL3_PATH}" NO_DEFAULT_PATH)
else()
    find_package(SDL3 QUIET)
    # ... normal SDL3 detection with error messages
endif()

The EMSCRIPTEN variable is automatically defined by Emscripten’s toolchain file. NO_DEFAULT_PATH ensures CMake does not accidentally find your native SDL3 installation and try to link it — that would produce a binary for the wrong platform entirely.

The second divergence is target exclusion. Demo executables, test harnesses, and debug tools are unnecessary (and often impossible) to build for the browser. Wrap them in a guard:

if(NOT EMSCRIPTEN)
    # PossessionDemo, TestChamberDemo,
    # AbilitiesObstaclesDemo, VerificationDemo,
    # tests, DebugLevelTest
    # ... all demo/test targets here
endif()

This keeps the Emscripten build focused on a single deliverable: the main MultiversalConsciousness target.

Surrendering the Game Loop: Why the Browser Must Drive the Frame Clock

This is the most important architectural change in the entire port, and it deserves a thorough explanation.

A native game engine typically owns its main loop. The GameEngine::run() method in Multiversal Consciousness contains a while (is_running_) loop that runs continuously, calling single_step() every frame, sleeping to maintain 60 FPS, and updating timing statistics. The engine is in control. It decides when to poll events, when to update, when to render, and when to sleep.

In a browser, this is impossible. The browser’s JavaScript runtime is single-threaded. If your C++ code enters an infinite while loop, it blocks the browser’s event loop entirely. The page freezes. No rendering occurs. No input events are processed. The browser may even kill the tab after a few seconds of unresponsiveness.

Instead, the browser provides requestAnimationFrame — a callback mechanism where you hand the browser a function, and the browser calls it once per frame (typically at 60 Hz, synchronized to the display’s refresh rate). Emscripten exposes this through emscripten_set_main_loop_arg().

Here is the callback function that replaces the infinite loop. It runs exactly once per frame, invoked by the browser’s animation scheduler:

#ifdef __EMSCRIPTEN__
#include <emscripten.h>

void emscripten_loop_callback(void* arg) {
    GameEngine* engine = static_cast<GameEngine*>(arg);

    if (!engine->is_running()) {
        emscripten_cancel_main_loop();
        return;
    }

    static auto last_time =
        std::chrono::high_resolution_clock::now();
    auto current_time =
        std::chrono::high_resolution_clock::now();
    float delta_time =
        std::chrono::duration<float>(
            current_time - last_time).count();

    constexpr float MAX_DELTA_TIME = 1.0f / 30.0f;
    delta_time = std::min(delta_time, MAX_DELTA_TIME);
    last_time = current_time;

    engine->single_step(delta_time);
}
#endif

Several critical details here:

  • static auto last_time: The static keyword is essential. This variable persists across calls, giving us frame-to-frame timing without any external state.
  • MAX_DELTA_TIME clamping: If the user switches tabs and comes back, delta_time could be enormous. Clamping to 1/30 of a second prevents the physics simulation from exploding.
  • emscripten_cancel_main_loop(): When the engine signals shutdown, this cleanly unregisters the callback from the browser’s animation frame scheduler.

Now the run() method branches based on the platform:

void GameEngine::run() {
#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop_arg(
        emscripten_loop_callback, this, 0, 1);
#else
    // Native: while loop with sleep_for timing
    while (is_running_) {
        // ... delta time, single_step, frame limiting
    }
#endif
}

The arguments to emscripten_set_main_loop_arg are:

ArgumentValueMeaning
callbackemscripten_loop_callbackFunction called each frame
argthisPassed as void* to the callback
fps0Use requestAnimationFrame (browser-controlled timing)
simulate_infinite_loop1Do not return from this call until the loop ends

Setting fps to 0 is the recommended approach. It lets the browser synchronize rendering with the display’s vsync, producing smoother animation than any manual timing loop could achieve.

The single_step() method is the key architectural enabler here. By factoring all per-frame logic (event polling, ECS update, rendering) into a single function, the engine can be driven either by an internal loop (native) or by an external callback (Emscripten) without any duplication of game logic. If your engine doesn’t already have this separation, refactoring to create it is the first step toward any Emscripten port.

Packing the Virtual Suitcase: Asset Preloading and Emscripten’s Filesystem

Native applications read files from disk at runtime. WebAssembly has no access to the user’s filesystem — that would be a catastrophic security hole. Instead, Emscripten provides a virtual filesystem backed by preloaded data that is bundled at compile time.

The --preload-file linker flag is how you populate this virtual filesystem. Here is how Multiversal Consciousness uses it:

target_link_options(${PROJECT_NAME} PRIVATE
    "SHELL:--preload-file ${CMAKE_SOURCE_DIR}/assets@assets"
    "SHELL:--preload-file ${CMAKE_SOURCE_DIR}/levels@levels"
)

The syntax source_path@virtual_path means: “Take everything in source_path on the build machine and mount it at virtual_path inside the virtual filesystem.” When the game calls fopen("assets/1 background/1.png", "rb") or SDL3’s IMG_Load("assets/City_men_1/Idle.png"), the virtual filesystem intercepts the call and serves the preloaded data.

At build time, Emscripten’s file packager serializes all matched files into a single .data file. This file is downloaded by the browser alongside the .wasm and .js files, and its contents are decompressed into the virtual filesystem before main() runs.

One thing to watch out for: the .data file contains all your preloaded assets uncompressed in memory. For Multiversal Consciousness, that means every sprite sheet, background layer, font, and level file. If your assets directory is 50 MB, your .data file will be roughly 50 MB, and the browser allocates that much memory just for the virtual filesystem before the game even starts. Audit your assets before shipping. Convert PNGs to WebP, downsample audio, or split assets into on-demand downloads if your game is large.

The SHELL: prefix in target_link_options tells CMake to pass the flag as a single shell token rather than splitting it on spaces. Without it, CMake would break --preload-file path@mount into separate arguments and Emscripten would not understand the command.

Crafting the Portal: The Shell HTML That Hosts Your Game

When Emscripten produces a .html output (enabled by set_target_properties(${PROJECT_NAME} PROPERTIES SUFFIX ".html")), it injects its JavaScript glue code into an HTML shell template. The --shell-file flag specifies which template to use:

"SHELL:--shell-file ${CMAKE_SOURCE_DIR}/shell_minimal.html"

Without this flag, Emscripten uses its default shell, which is functional but ugly. The custom shell_minimal.html for Multiversal Consciousness provides a themed loading experience, error handling, and proper canvas setup. Let us walk through each piece.

The page uses a dark theme that matches the game’s aesthetic. CSS custom properties make it easy to adjust:

<style>
    :root {
        --bg-color: #050508;
        --accent-color: #4facfe;
    }
    body {
        background: var(--bg-color);
        color: #e0e0e0;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
    }
    canvas {
        image-rendering: pixelated;
        image-rendering: crisp-edges;
    }
</style>

The image-rendering: pixelated declaration is critical for pixel-art games. Without it, the browser applies bilinear filtering when scaling the canvas, turning crisp sprites into blurry smears.

The loading screen displays a spinner animation and progress bar while assets download:

<div id="loading">
    <div class="spinner"></div>
    <div id="status">Syncing Realities...</div>
    <div class="progress-bar">
        <div id="progress" style="width: 0%"></div>
    </div>
</div>

<canvas id="canvas"
        oncontextmenu="event.preventDefault()"
        tabindex="-1">
</canvas>

The oncontextmenu prevention stops right-click from opening a browser context menu over the game. The tabindex="-1" ensures the canvas can receive keyboard focus for input events, which is essential for SDL’s event system to work in the browser.

The JavaScript Module object is the bridge between your shell HTML and Emscripten’s glue code. You define it before the glue script loads, and Emscripten merges its properties:

var Module = {
    preRun: [],
    postRun: [],
    print: function(text) { console.log(text); },
    canvas: document.getElementById('canvas'),
    setStatus: function(text) {
        var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
        var progress = document.getElementById('progress');
        if (m) {
            // Update progress bar with download percentage
            var pct = (parseInt(m[2]) / parseInt(m[4])) * 100;
            progress.style.width = pct + '%';
        }
        if (text === '') {
            // Loading complete -- hide the loading screen
            document.getElementById('loading')
                .style.display = 'none';
        }
    },
    monitorRunDependencies: function(left) {
        Module.setStatus(
            left
                ? 'Preparing... (' + left + ' dependencies)'
                : 'All downloads complete.');
    }
};

The setStatus function deserves attention. Emscripten calls it with strings like "Downloading data... (3145728/10485760)" during asset loading. The regex parses out the current and total byte counts to drive the progress bar. When the string is empty, loading is complete, and the loading overlay is hidden to reveal the canvas.

A WebGL context-loss handler prevents silent failures:

canvas.addEventListener('webglcontextlost', function(e) {
    alert('WebGL context lost. Please reload the page.');
    e.preventDefault();
}, false);

And an error handler provides a themed crash message:

window.onerror = function() {
    var status = document.getElementById('status');
    status.textContent = 'Reality Collapse Detected.';
    status.style.color = 'red';
};

At the bottom of the shell HTML, the {{{ SCRIPT }}} placeholder is where Emscripten injects the compiled JavaScript glue code during the build. This is a template directive that Emscripten’s HTML processor replaces with a <script> tag pointing to (or inlining) the generated .js file:

{{{ SCRIPT }}}

You never write this script yourself. Emscripten generates it from your compiled C++ code, and it contains the WebAssembly loader, the virtual filesystem initializer, and all the bindings between JavaScript and your C++ functions.

Decoding the Linker Flags: What Every Emscripten Option Controls

Each -s flag passed to Emscripten’s linker controls a specific aspect of the generated WebAssembly module. Let us examine every flag used in the Multiversal Consciousness build:

target_link_options(${PROJECT_NAME} PRIVATE
    "SHELL:-s USE_SDL=0"
    "SHELL:-s USE_ZLIB=1"
    "SHELL:-s ALLOW_MEMORY_GROWTH=1"
    "SHELL:-s NO_EXIT_RUNTIME=1"
    "SHELL:-s EXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
)

Here is what each flag does and why it matters:

USE_SDL=0 — This tells Emscripten not to use its built-in SDL port. Emscripten ships with SDL2 as a built-in port (enabled via USE_SDL=2), but Multiversal Consciousness uses SDL3, which is not available as an Emscripten port. Setting this to 0 prevents Emscripten from injecting its SDL2 headers and libraries, which would conflict with the externally-built SDL3 libraries you are linking against.

If you forget USE_SDL=0, Emscripten may silently pull in SDL2 headers. Your code will compile against SDL2’s API, producing subtle type mismatches and missing function errors that are extremely difficult to diagnose. Always explicitly disable built-in ports when using externally-built libraries.

USE_ZLIB=1 — Enables Emscripten’s built-in zlib port. SDL3_image and SDL3_ttf both depend on zlib for compressed image formats (PNG) and compressed font data. Without this, you would get unresolved symbol errors during linking for functions like inflate and deflate.

ALLOW_MEMORY_GROWTH=1 — By default, WebAssembly modules are allocated a fixed amount of linear memory (typically 16 MB). If your program exceeds this, it crashes. ALLOW_MEMORY_GROWTH lets the module request more memory from the browser at runtime, similar to how malloc can request more pages from the OS. This is essential for a game engine that dynamically allocates textures, entity components, and level data.

Memory growth has a performance cost. When the WebAssembly heap grows, the browser must allocate a new ArrayBuffer and copy the old data — this causes frame hitches. If you know your game’s peak memory usage, set INITIAL_MEMORY=67108864 (64 MB) to reduce the number of growth events, while keeping ALLOW_MEMORY_GROWTH=1 as a safety net.

NO_EXIT_RUNTIME=1 — Normally, when main() returns, Emscripten tears down the runtime: it frees memory, closes file handles, and runs atexit handlers. But because emscripten_set_main_loop_arg is called with simulate_infinite_loop=1, the call to main() does not return until the game loop ends. NO_EXIT_RUNTIME ensures that even if main() does return (e.g., due to an error), the runtime stays alive so that any pending callbacks, timers, or async operations complete cleanly.

EXPORTED_RUNTIME_METHODS=['ccall','cwrap'] — This makes Emscripten’s ccall and cwrap JavaScript helper functions available in the Module object. These are useful for calling C++ functions from JavaScript (e.g., for a web-based settings panel or analytics integration). Even if you are not using them immediately, exporting them costs nothing and enables future extensibility.

Getting It Running and Shipping It

With all the pieces in place, create a separate build directory — never mix Emscripten and native builds — and invoke CMake through Emscripten’s wrapper:

mkdir build-wasm && cd build-wasm
emcmake cmake .. -DCMAKE_BUILD_TYPE=Release
emmake make

emcmake injects Emscripten’s toolchain file into the CMake configuration, setting the compiler to emcc and defining the EMSCRIPTEN variable that all the conditional logic depends on. emmake is a thin wrapper around make that keeps the Emscripten environment active during compilation.

When the build completes, you’ll find four files in build-wasm/:

FilePurposeTypical Size
MultiversalConsciousness.htmlEntry point HTML page (from your shell template)~5 KB
MultiversalConsciousness.jsJavaScript glue code (WebAssembly loader, API bindings)~200-500 KB
MultiversalConsciousness.wasmCompiled WebAssembly binary (your entire C++ engine)~2-10 MB
MultiversalConsciousness.dataPreloaded assets (sprites, levels, fonts)Varies

To test locally, you must serve these files over HTTP — not directly via file://. Browsers block fetch() on file:// URLs for security reasons:

python3 -m http.server 8080
# Then open http://localhost:8080/MultiversalConsciousness.html

You should see “Syncing Realities…” on the loading screen, the progress bar fill as assets download, and then the game canvas appear. For production, upload all four files to any static host — GitHub Pages, Netlify, Vercel, S3 — just make sure they’re all served from the same origin.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!