Tutorial 124: WebAssembly Gotchas: Game Lifetime, Storage and Video
What you’ll learn
- Why a stack-allocated
Gameis 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
VideoandVideoPlayerare absent from web builds — at link time, not run 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.
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:
| 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 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
Gameheap-allocated under__EMSCRIPTEN__? - Does anything call
StorageDeviceexpecting it to survive a reload? - Does anything reference
VideoPlayerwithout 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_DOMhas automatic browser CI — everything else is on you.