Tutorial 124: WebAssembly Gotchas: Game Lifetime, Storage and Video

CNA Tutorials  ·  Platforms

What you’ll learn

  • Why a stack-allocated Game is silently corrupted on the web, and how to avoid it.
  • That saved games do not persist at all in the browser, and what to do instead.
  • That Video and VideoPlayer are absent from web builds — at link time, not run time.
  • Which renderers you can actually select under Emscripten.

Before you startTutorial 81: Building for WebAssembly covers the toolchain and the build itself. This tutorial is about the three things that will bite you after the build succeeds.

A CNA game that runs perfectly on the desktop can compile for the browser, start up, and then misbehave in ways that make no sense — a crash several frames later with no stack, saves that vanish on refresh, a link error mentioning symbols you never touched. All three have the same cause: the browser is not a small desktop, and CNA is explicit about where it stops pretending otherwise.

1. Your Game must be heap-allocated

On Emscripten, allocate your Game subclass with new. A stack-allocated one is silently corrupted. This is the single most important thing on this page, because it does not fail loudly.

The mechanism is worth understanding, because the symptom is so far from the cause. Emscripten's emscripten_set_main_loop(..., simulateInfiniteLoop = 1) is implemented as a raw JavaScript throw 'unwind'. Under -fwasm-exceptions, the cleanup landing pad that C++ generates for a local object with a non-trivial destructor genuinely catches it. So your Game's destructor runs, for real, at the Run() call site — freeing the GraphicsDeviceManager and leaving Game::graphicsDeviceManager_ dangling while the main loop is still scheduled to run.

Nothing reports an error. The game keeps going, and then some frames later the WebAssembly runtime faults with something like table index is out of bounds or null function or function signature mismatch.

// WRONG on Emscripten - compiles, starts, then corrupts itself
int main()
{
    MyGame game;          // stack-allocated: destructor runs on the unwind throw
    game.Run();
    return 0;
}

// CORRECT - the Game outlives Run() because nothing destroys it
int main()
{
    auto* game = new MyGame();   // deliberately never deleted
    game->Run();
    return 0;
}

Deliberately leaking here is the right call: on the web the page teardown reclaims everything anyway, and the alternative is a use-after-free. Native builds are unaffected, so if you share one main() across targets, guard it:

int main()
{
#ifdef __EMSCRIPTEN__
    auto* game = new MyGame();
    game->Run();
#else
    MyGame game;
    game.Run();
#endif
    return 0;
}

2. Saved games do not persist

Every save written through StorageDevice/StorageContainer is discarded when the page reloads. The write succeeds and reports success — the data simply is not there next time.

Under Emscripten, SDL_GetPrefPath() resolves into MEMFS, Emscripten's in-memory filesystem. CNA mounts no IDBFS and never calls FS.syncfs, so nothing is ever copied into IndexedDB. The XNA storage API works exactly as documented, against a filesystem that ceases to exist when the tab closes.

If your game targets the browser and needs persistence, do not route it through StorageDevice. Write through your own localStorage or IndexedDB path instead, and keep the XNA storage path for native builds:

#ifdef __EMSCRIPTEN__
#include <emscripten.h>

void SaveProgress(const std::string& json)
{
    // Persist through the browser's own storage rather than the C++ filesystem
    EM_ASM({ localStorage.setItem('save', UTF8ToString($0)); }, json.c_str());
}
#else
void SaveProgress(const std::string& json)
{
    // Real StorageContainer write - see Tutorial 19
}
#endif

See Tutorial 19: Saving and Loading Data for the native path, and the Storage reference.

3. Video is not in the build at all

On Windows, Emscripten and Android, CNA's three video translation units are excluded from the build entirely. The headers still exist, so code that calls Video or VideoPlayer compiles cleanly — and then fails at link time with undefined symbols.

This catches people out because it is not a runtime stub you can probe for. There is no "is video supported" query to call; the symbols are missing. Guard video features with your own build-time switch:

#if !defined(__EMSCRIPTEN__) && !defined(_WIN32) && !defined(__ANDROID__)
    #define GAME_HAS_VIDEO 1
#endif

#ifdef GAME_HAS_VIDEO
    videoPlayer->Play(introVideo);
#else
    SkipToMainMenu();
#endif

Which renderers you can choose

Emscripten builds are restricted to five renderers, and CMake enforces it with a hard error rather than a warning:

RendererScopeNotes
WEBGL22D + 3DThe Emscripten default. GLES 3.0 through the shared EasyGL implementation.
WEBGL12D + 3DGLES 2.0. Genuinely loses MRT, occlusion queries, Texture3D, instancing and multi-stream vertex input.
CANVAS2D onlyBrowser-native 2D canvas, no GPU context.
HTML_DOM2D onlySprites as CSS-transformed DOM elements. Has real headless-Chromium CI.
SVG_DOM2D onlySprites as real SVG elements with feColorMatrix tinting.

Selecting a desktop renderer for a web build is a configure-time FATAL_ERROR, not a silent fallback — which is the behaviour you want. Note that EASYGL is no longer a valid value anywhere; use WEBGL2.

Pre-ship checklist

  • Is your Game heap-allocated under __EMSCRIPTEN__?
  • Does anything call StorageDevice expecting it to survive a reload?
  • Does anything reference VideoPlayer without a build-time guard?
  • Have you actually reloaded the page mid-game and checked what came back?
  • Have you tested in a real browser, not just Node? Only HTML_DOM has automatic browser CI — everything else is on you.

Where to go next