Tutorial 124: WebAssembly Gotchas: Game Lifetime, Storage and Video
What you’ll learn
- Why alpha.1 needed a heap-allocated
Gameon the web, what replaced that rule, and the one link option you still need. - That saved games do not persist at all in the browser, and what to do instead.
- That
VideoandVideoPlayerlink on the web but throwNotSupportedException, 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.
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 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 next branch, 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 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, StorageDevice resolves its per-user directory ($XDG_DATA_HOME/<game> or $HOME/.local/share/<game>, exactly as on Linux) inside Emscripten's virtual filesystem, which is in-memory (MEMFS) by default. 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.
A note on sharp-runtime’s IsolatedStorage. The sibling sharp-runtime library has its own System::IO::IsolatedStorage API whose Emscripten path assumes your startup code mounts an IDBFS filesystem at /save, and which calls FS.syncfs when a stream closes. That is a different API from CNA’s StorageDevice, CNA itself mounts nothing, and this tutorial has not verified it as a persistence route for XNA-style saves — treat it as something to investigate, not as a supported answer.
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
Five renderers exist only under Emscripten, and CMake refuses the desktop GL profiles there (and the Windows-only and macOS-only renderers) with a hard error rather than a warning. It does not limit an Emscripten build to these five: WEBGPU has its own browser route, and renderers that need no operating-system API, such as SOFTWARE, HEADLESS and STUB, are not refused by name. The five browser-specific ones are:
| Renderer | Scope | Notes |
|---|---|---|
WEBGL2 | 2D + 3D | The Emscripten default. GLES 3.0 through the shared EasyGL implementation. |
WEBGL1 | 2D + 3D | GLES 2.0. Genuinely loses MRT, occlusion queries, Texture3D, instancing and multi-stream vertex input. |
CANVAS | 2D only | Browser-native 2D canvas, no GPU context. |
HTML_DOM | 2D only | Sprites as CSS-transformed DOM elements. Has real headless-Chromium CI. |
SVG_DOM | 2D only | Sprites as real SVG elements with feColorMatrix tinting. |
Selecting OPENGLES2, 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 no longer 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 yourGameheap-allocated)? - Does anything call
StorageDeviceexpecting it to survive a reload? - Does anything reference
VideoPlayerwithout aCNA_VIDEO_AVAILABLEguard 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? Only
HTML_DOMhas an automatic browser-runtime workflow (a separate multi-renderer workflow builds and linksWEBGL2;CANVAS;HTML_DOM;SVG_DOMbut does not run them) — everything else is on you.
Where to go next
- Tutorial 81: Building for WebAssembly
- Tutorial 19: Saving and Loading Data
- Tutorial 100: Shipping Your CNA Game
- Platform support reference
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- The web target: Emscripten build contract, browser loop, storage and renderer evidence — CNA's Emscripten build contract (exception ABI, Asyncify, threads), the Asyncify browser loop, content and save storage in the virtual file system, web networking, and the evidence per browser renderer.