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

CNA Tutorials  ·  Platforms

ℹ

What you’ll learn

  • Why alpha.1 needed a heap-allocated Game on the web, what replaced that rule, and the one link option you still need.
  • How saved games persist in the browser through IndexedDB, and the threaded-build exception.
  • That Video and VideoPlayer link on the web but throw NotSupportedException, and how to guard them at build time.
  • Which renderers you can actually select under Emscripten.

Before you start — Tutorial 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 — one of which is mostly history in this snapshot, but still bites anyone on the alpha.1 tag, and one of which now only bites threaded builds.

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 (alpha.1), saves that vanish on refresh (a threaded build), a video call that throws. All three have the same cause: the browser is not a small desktop, and CNA is explicit about where it stops pretending otherwise.

1. Game lifetime: what changed since alpha.1

ℹ

In this snapshot a stack-allocated Game is fine on Emscripten — if your final executable links CNA::EmscriptenAsyncify. On the alpha.1 tag it was not: the heap-allocation rule below applied there, and still applies to anyone building against that tag.

What alpha.1 did

The mechanism is worth understanding, because the symptom was so far from the cause. At alpha.1 Game::Run() ended in emscripten_set_main_loop(..., simulateInfiniteLoop = 1), which Emscripten implements 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 a local Game's destructor ran, for real, at the Run() call site — freeing the GraphicsDeviceManager and leaving Game::graphicsDeviceManager_ dangling while the main loop was still scheduled to run.

Nothing reported an error. The game kept going, and then some frames later the WebAssembly runtime faulted with something like table index is out of bounds or null function or function signature mismatch. The workaround was to allocate the game with new and never delete it.

What this snapshot does

Since Game::Run() was reworked (commit dccfb88f8, 22 August 2026), the Emscripten RunLoop() runs one frame body and then awaits requestAnimationFrame() through EM_ASYNC_JS, suspending the same WebAssembly stack through Asyncify between frames. The caller is never unwound, so Game::Run() keeps XNA’s blocking contract: it runs until Exit() and then returns. Ordinary ownership works on the web exactly as it does natively:

// Correct on every target in this snapshot, Emscripten included
int main()
{
    MyGame game;          // a local object is fine
    game.Run();           // returns after Exit()
    return 0;
}

The price is the link step. CNA links Asyncify into executables that live inside its own source tree automatically; an external final executable that uses a blocking Game::Run() must link it itself:

target_link_libraries(MyGame PRIVATE CNA CNA::EmscriptenAsyncify)   # -sASYNCIFY=1

(CNA::EmscriptenAbi is the compatibility composition of the JavaScript-lowered exception ABI and Asyncify.) Without Asyncify the frame loop cannot suspend, which is a build-configuration problem to fix rather than something to work around by leaking the Game.

Building against alpha.1?

If you are on the v0.1.0-alpha.1 tag rather than the documented development snapshot, keep the old rule. The alpha.1 code is:

// alpha.1 only
int main()
{
#ifdef __EMSCRIPTEN__
    auto* game = new MyGame();   // deliberately never deleted: page teardown reclaims everything
    game->Run();
#else
    MyGame game;
    game.Run();
#endif
    return 0;
}

The guarded form is harmless at this snapshot too, so a codebase that must build against both can keep it. CNA’s own docs/emscripten-mainloop-game-lifetime.md records the new contract and keeps a spike that reproduces the old unwinding behaviour.

2. Saved games persist through IndexedDB — except in threaded builds

⚠

Saves written through StorageDevice/StorageContainer survive a page reload in a default web build, but not in a default threaded one. A threaded build uses WasmFS, which cannot host the browser storage CNA mounts, so there StorageDevice reports storage as unavailable unless you configure -DCNA_EMSCRIPTEN_USE_WASMFS=OFF.

Under Emscripten, CNA’s storage module links Emscripten’s IDBFS and, before main() runs, mounts it at /cna-storage for StorageDevice (whose root becomes /cna-storage/<app>) and at /save for IsolatedStorage, then restores both from IndexedDB. Both mounts use IDBFS’s automatic persistence, so closing or deleting a file is flushed to IndexedDB without any call from your game. If the mount or the restore fails, StorageDevice throws StorageDeviceNotConnectedException (“Persistent browser storage is unavailable.”) rather than quietly saving to memory. The XNA storage API therefore works unchanged: write saves through StorageContainer exactly as on the desktop.

Threaded builds (-DCNA_ENABLE_EMSCRIPTEN_THREADS=ON) use WasmFS by default, and WasmFS cannot link IDBFS, so the mounts are absent there. -DCNA_EMSCRIPTEN_USE_WASMFS=OFF restores persistence; CNA’s documentation keeps WasmFS recommended for games that load content on worker threads, because the legacy file system can wait on the browser’s main thread during background loads. Whatever the build, files written outside /cna-storage and /save live in memory and are gone on reload.

#include "Microsoft/Xna/Framework/Storage/StorageDeviceNotConnectedException.hpp"

void SaveProgressSafely(const std::string& json)
{
    try
    {
        SaveProgress(json);   // StorageDevice / StorageContainer, exactly as in Tutorial 19
    }
    catch (const Microsoft::Xna::Framework::Storage::StorageDeviceNotConnectedException&)
    {
        // In a browser: IndexedDB could not be mounted, or this is a threaded WasmFS build.
        // Tell the player that progress will not be kept.
    }
}

See Tutorial 19: Saving and Loading Data for the save code itself, and the Storage reference. Browser storage belongs to the page’s origin and the player can clear it, so test a reload in every browser you ship to.

ℹ

Two stores that are not StorageDevice. The sibling sharp-runtime library has its own System::IO::IsolatedStorage API whose Emscripten path writes under /save and calls FS.syncfs when a stream closes; CNA now mounts IndexedDB-backed storage there, but this tutorial has not verified IsolatedStorage as a save route of its own. And CNA’s Gamer Services keeps its local profiles outside both mounts, so by a reading of the source, local profiles are not expected to persist in a browser (not tested).

3. Video needs FFmpeg, which the web never gets

On Windows, Emscripten, Android and iOS, CNA never builds its FFmpeg video backend. The Video and VideoPlayer types are present in every build and link fine, so code that calls them compiles cleanly — but the first operation that needs a decoder throws System::NotSupportedException at run time (constructing a Video from an existing file, or calling VideoPlayer::Play), with a message saying CNA was built without the optional FFmpeg video backend. (alpha.1 behaved differently: there the video symbols were missing and the failure was a link error.)

You can decide at build time. When the FFmpeg backend is built in, CNA defines CNA_VIDEO_AVAILABLE for everything that links CNA; on the web it is never defined:

#ifdef CNA_VIDEO_AVAILABLE
    videoPlayer->Play(introVideo);   // videoPlayer: VideoPlayer*, introVideo: Video*
#else
    SkipToMainMenu();
#endif

Catching System::NotSupportedException around the same calls also works as a run-time fallback. On native Linux and macOS the same switch applies: FFmpeg is optional (CNA_ENABLE_VIDEO=AUTO|ON|OFF), so a build made without the FFmpeg development packages behaves like the web build here.

Which renderers you can choose

One renderer exists only under Emscripten, WEBGL2 — the Emscripten default, OpenGL ES 3.0 semantics through WebGL 2 and the shared EasyGL implementation, 2D and 3D. CMake refuses the desktop GL profiles there (and the Windows-only renderers and METAL) with a hard error rather than a warning. That does not limit an Emscripten build to WEBGL2: WEBGPU has its own experimental browser route, and renderers that need no operating-system API, such as SOFTWARE, HEADLESS and STUB, are not refused by name. Tutorial 105 compares WEBGL2 with WEBGPU.

Selecting OPENGLES3, OPENGL33, a Windows-only renderer or METAL for a web build is a configure-time FATAL_ERROR, not a silent fallback — which is the behaviour you want. Note that EASYGL is not a valid value anywhere; use WEBGL2.

Pre-ship checklist

  • Does your final web executable link CNA::EmscriptenAsyncify (and, if you build against alpha.1 instead, is your Game heap-allocated)?
  • If the web build is threaded, did you configure -DCNA_EMSCRIPTEN_USE_WASMFS=OFF, or handle StorageDeviceNotConnectedException and tell the player that saves are not kept?
  • Does anything reference VideoPlayer without a CNA_VIDEO_AVAILABLE guard or an exception handler?
  • Have you actually reloaded the page mid-game and checked what came back?
  • Have you tested in a real browser, not just Node? No CNA workflow runs a browser: the multi-renderer workflow builds and links WEBGL2;WEBGPU but does not run them — everything else is on you.

Where to go next