The web target: Emscripten build contract, browser loop, storage and renderer evidence
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the Emscripten CMake, Game.cpp, StorageDevice.cpp, ENetBackend.cpp, the storage pre-run script, the browser runner script and the web workflow at c1c316b9, and Sharp Runtime next @ 41b918c9 for isolated storage. Nothing was built, served or run in a browser; save persistence is a reading of the code and of CNA's own records.
CNA has no separate JavaScript implementation: its web build sends the same C++23 framework through Emscripten and links it with SDL3's browser port. WebAssembly keeps most of the language and loses much of the process model, so the hard parts of a web port sit at its boundaries: the exception and suspension ABI every object must share, a loop the browser schedules, content that has to be put into a virtual file system, saves that need a browser-backed file system, sockets that cannot listen, and a verdict that has to leave a process whose exit status nobody can observe. This page explains each boundary at this snapshot and then separates the kinds of evidence that exist for the browser renderers. The task-level walk-throughs are Tutorial 81, Tutorial 124 and Tutorial 105.
The toolchain changes the execution contract
A web configure runs under emcmake (the web preset names no toolchain file, so it is run as emcmake cmake --preset web). The platform implementation is SDL3; CNA_PLATFORM=EMSCRIPTEN is a reserved name that is refused. With no explicit choice the renderer is WEBGL2, the one identity that exists only under Emscripten (EasyGL's browser profile); the native GL profiles OPENGLES3 and OPENGL33 are refused with a message that points to WEBGL2 (RendererSelection.cmake), and the Windows-only identities and METAL are refused by their own host gates. No rule limits an Emscripten configure to WEBGL2: WEBGPU has a browser route through the emdawnwebgpu port, and identities that need no operating-system API, such as SOFTWARE, HEADLESS and STUB, are not refused by name (no workflow builds them for the web). One configure is not limited to one renderer: CNA_GRAPHICS_RENDERERS can link several browser renderers into one bundle and let the page choose at run time, which is what the multi-renderer web workflow builds.
When the default identity is WEBGL2, cna_apply_emscripten_renderer_link_contract() links the executable with -sMIN_WEBGL_VERSION and -sMAX_WEBGL_VERSION both pinned to 2, so the build never accepts Emscripten's default of a WebGL context version 1 and never emits GLSL ES 3.00 into such a context; the WebGPU family inherits no GL requirements from it. EasyGL, the sibling library behind WEBGL2, is configured with EASYGL_EMSCRIPTEN_EXCEPTION_MODEL=JS when embedded, so it joins CNA's exception ABI.
One exception ABI for every frame
CNA uses C++ exceptions in ordinary error paths, and under Emscripten every C++ frame that can propagate one has to use the same exception model. BuildPerformance.cmake defines cna_emscripten_exception_abi as JavaScript-lowered exceptions (-fexceptions when compiling, -fexceptions -sDISABLE_EXCEPTION_CATCHING=0 when linking), links it privately to every CNA-owned target, and publishes it through cna_build_config (CNA::BuildConfig; the exception interface is also exported as CNA::EmscriptenExceptionAbi) so a consumer's own translation units and final link use the same model (modules/CMakeLists.txt). The same interface adds -sSTACK_SIZE=1048576; CNA's own web example targets additionally link -sALLOW_MEMORY_GROWTH=1. Earlier revisions used native WebAssembly exceptions (-fwasm-exceptions); that choice interacted with the old main loop as described below, and it is not the model at this snapshot.
Exceptions that escape before the loop starts need special care on the web. Game::Run() catches std::exception around initialisation, logs it and rethrows (Game.cpp), because an exception that leaves Run() reaches the browser as a rejected promise carrying only an opaque exception pointer, with no type, no what() and nothing in the console. On a native build the extra line is harmless.
Asyncify is an executable policy, not a library property
Blocking Game::Run() on the web needs the WebAssembly stack to be suspended and resumed between frames, which Emscripten's Asyncify provides. It is a separate interface target, cna_emscripten_asyncify (-sASYNCIFY=1), and it is applied only to CNA-owned EXECUTABLE targets, unless a target sets the property CNA_EMSCRIPTEN_ASYNCIFY to OFF. The C API's WebAssembly artifact does exactly that and adds -sASYNCIFY=0: a JavaScript-driven library must keep ordinary synchronous exports and let the browser schedule its per-frame calls, and a CheckWasmLinkContract script checks that no later flag re-enables it (C API CMake). An application outside CNA's tree that uses a blocking Game::Run() must link CNA::EmscriptenAsyncify itself; CNA::EmscriptenAbi is the compatibility composition of the exception ABI and Asyncify. The test executable asks for a different mechanism: CnaTests links -sJSPI=1 and -sEXIT_RUNTIME=1 so that loopback networking tests could yield to Node's event loop and the process exits after printing its results (see configuring CNA: Emscripten). It is also an executable without the opt-out, so it receives -sASYNCIFY=1 as well, and by a reading of the link options plus a one-file probe with a newer emsdk, emcc lets the later flag win: the binary would be an Asyncify build and the JSPI request has no effect (CNA-BUG-262; the CnaTests link itself was not run).
Threads are an application-wide ABI choice
A default web build is single-threaded. CNA_ENABLE_EMSCRIPTEN_THREADS (default OFF, and a configure error on any non-Emscripten target; CMakeLists.txt) switches the whole application to the shared-memory pthread ABI: -pthread on every compile and link, Sharp Runtime's own web thread support forced on, and on the final link -sOFFSCREEN_FRAMEBUFFER=1 and, unless CNA_EMSCRIPTEN_USE_WASMFS is set to OFF (default ON), -sWASMFS=1. The WasmFS default has a recorded reason: the legacy JavaScript file system proxies every syscall of a pthread to the browser main thread, which can deadlock a background Content.Load while the game loop is active (the comment names Firefox), whereas WasmFS keeps preloaded-file operations inside WebAssembly. The cost is persistent storage, because WasmFS cannot link IDBFS (see saves). A threaded build can run the game thread itself on a worker, as CNA.NET's threaded browser bundles do: such a build links 64 MB of initial memory for the C ABI module, a patched SDL commits each frame that the worker's proxied WebGL context draws, and StorageDevice asks the page's thread whether storage is ready (CBIND-151; CNA records headless-Chromium runs of three games, not executed for this page). Threaded and single-threaded archives cannot be linked together, so SDL's persistent install has two roots, .sdl-prebuilt-emscripten and .sdl-prebuilt-emscripten-pthreads. The browser offers shared memory only to cross-origin-isolated pages; providing those response headers is the host page's job, not something the build emits (see Tutorial 78).
Content has to cross into the virtual file system
A native executable can find files beside its working directory. A page cannot: Emscripten exposes a virtual file system, and a file exists there only if the page creates it or the linker packages it. ContentManager resolves an asset through std::filesystem and then through the platform's file service, and both look into that virtual tree. In CNA's own tree exactly two build sites package content, both with --preload-file <dir>/Content@/Content: the 2D graphics demo (graphics examples) and the sound demo (audio examples). There is no project-wide asset manifest, no --embed-file policy and no custom shell file that fills the gap. A successful link therefore proves nothing about content: each web application must account for every name that can reach ContentManager or TitleContainer, and two builds of the same commit can carry different content if their preload mappings differ.
Saves persist through IndexedDB, except in a default threaded build
StorageDevice resolves its root independently of the platform implementation (StorageDevice.cpp, EnsureStorageRoot). On a desktop that is $XDG_DATA_HOME/<game>, else $LOCALAPPDATA/<game>, else $HOME/.local/share/<game> (~/Library/Application Support/<game> on Apple), else a directory under the current path. Under Emscripten the root is /cna-storage/<game>, a directory the storage module prepares itself: whenever the build uses Emscripten's legacy file system, cna_storage puts -lidbfs.js and a pre-run script, WebStoragePre.js, on its consumers' link (storage CMake). Before main() that script mounts IDBFS with autoPersist at /cna-storage and at /save, restores both from IndexedDB with FS.syncfs and holds a run dependency until the restore has finished, so files saved on an earlier visit are visible to the first XNA call and later writes are flushed back as they happen. If the mount or the restore failed, StorageDevice does not fall back to memory: it throws StorageDeviceNotConnectedException ("Persistent browser storage is unavailable."). Emscripten's Node runner, which has no IndexedDB, keeps its process-local file system. CNA records one save, reload and load of the original save files in Chrome for its Role Playing Game port (SAMPLE-070 in NEXT.md); nothing was run for this page.
A threaded build is the exception. It uses WasmFS by default, WasmFS cannot link IDBFS, and the storage module therefore adds neither mount: StorageDevice reports storage as unavailable rather than pretending that an in-memory save is persistent. Configuring -DCNA_EMSCRIPTEN_USE_WASMFS=OFF puts a threaded build on the legacy file system and restores persistence, at the cost described under threads: legacy file-system calls from a worker can wait on the browser main thread, so WasmFS stays the better choice for a game that loads content on worker threads. The SDL3 platform's preference path (GetPreferencesPath, through SDL_GetPrefPath) is also inside the virtual file system, outside both mounts, and StorageDevice does not use it.
Sharp Runtime's separate System::IO::IsolatedStorage uses the second mount: on Emscripten its root is /save/.cna_isolated_storage, and closing an isolated-storage stream calls FS.syncfs (sharp-runtime next @ 41b918c9, StoragePaths.cpp and IsolatedStorageFileStream.cpp). Because CNA's pre-run script mounts and restores /save, isolated storage persists under the same conditions as StorageDevice; StorageDevice itself does not go through it.
Networking changes topology, not only transport
A browser tab can open outbound connections but can never listen, and Emscripten's socket layer does not report a usable ephemeral port. ENetBackend.cpp therefore hosts on the fixed port 61191 instead of ENET_PORT_ANY (meaningful only for a Node-run relay or server build) and rebuilds the joining side as an outbound-only ENet client; UDP-broadcast discovery is compiled out, so Find returns nothing; and EndJoin does not pump the handshake synchronously on the web. A web build can keep SystemLink message transport while losing native hosting and LAN discovery, so network evidence must name the role and the host, not only "ENet linked". Browser multiplayer is also outside CNA's current networking scope by an owner decision: a configured gamer-services endpoint and the online relay are refused with browser-specific transport errors, a browser build has local profiles only, and voice and the service event channel are native only (browser-network-readiness.md). The session-level consequences are on network sessions: the web.
The browser loop at this snapshot
On the desktop, Game::RunLoop() ticks while RunApplication is true. Under Emscripten it stores the game in one static slot and alternates one frame body, EmscriptenMainLoopCallback(), with CNA_WaitForAnimationFrame(), an EM_ASYNC_JS function that awaits requestAnimationFrame. Asyncify suspends and resumes the same WebAssembly stack between frames, so Run() blocks until the game exits and then returns, as it does natively (a fatal exception inside a frame is the exception, see below and CNA-BUG-080): a stack-local MyGame game; game.Run(); is valid, and after the loop Run() calls EndRun() and AfterLoop(). The source comment records why the frame body is called directly: a separately registered browser callback could not re-enter the instance while Run()'s stack is suspended.
The frame body is still not the desktop Tick. By reading Game.cpp: time comes from the platform's GetTicksMilliseconds() rather than the performance counter; a frame's delta is capped at a hard-coded 250 ms rather than MaxElapsedTime (500 ms); every frame drains whole TargetElapsedTime steps, so IsFixedTimeStep = false is not honoured; IsRunningSlowly is set false before every update; a frame draws only if at least one update ran, and SuppressDraw() is never consulted; Exiting is raised inside the frame body; a frame exception is caught, logged and ends the loop without Exiting; and the mobile suspend handling is not part of this path. None of these stops a demo from drawing, and each can change game behaviour, tests, shutdown or timing-sensitive networking, which is why they belong to the compatibility contract. The full comparison is on one frame source trace and game time and the timestep; the control flow is on the Game class.
A historical case: the hand-off that destroyed the game
The current design exists because the earlier one failed in an instructive way; CNA's own emscripten-mainloop-game-lifetime.md records the former implementation and the contract that replaced it. Earlier revisions (alpha.1 among them) ended Run() in emscripten_set_main_loop with simulateInfiniteLoop = 1, which Emscripten implements as a raw JavaScript throw 'unwind'. Under native WebAssembly exceptions, the cleanup landing pad generated for a local object with a destructor caught that foreign throw, so a stack-local game was destroyed at the call site while the registered callback still held pointers into it, including the graphics device manager. The page built, loaded and started; the first scheduled frame then touched dead state.
The way the fault was located is the reusable part (a historical account; the tree still holds the investigation as the spike spikes/emscripten-mainloop-stack-spike, whose README records the hypothesis ruled out first, the sanitizer and SAFE_HEAP runs and the decisive repro.cpp, with a build line that still uses -fwasm-exceptions, the ABI CNA no longer uses, and as GameServiceContainerTests.cpp, which the investigation added for the multiple-inheritance case). The first hypothesis blamed pointer adjustment across multiple inheritance in the graphics manager, which looked plausible because a stale-looking address crossed an interface boundary. Focused reproductions under AddressSanitizer, UndefinedBehaviorSanitizer with vptr checks and Emscripten's SAFE_HEAP did not support it. An isolated lifetime reproduction did: the decisive evidence was temporal rather than structural, the sequence constructor, hand-off, destructor, callback. That located the defect in the hosting contract shared by every web renderer instead of in any one renderer. The interim workaround allocated the game with new and never deleted it, and that migration carried a second lesson: a search-based fix must end with a search for survivors, not with a count of edited files, because an entry point that is built only for the web is easy to miss. At this snapshot the benchmark constructs its game on the stack, as do most entry points; 28 example entry points, among them demo_2d and demo_sound, the two demos that package content for the web, instead allocate the game with new and delete it after Run() returns. Both forms are correct under the Asyncify loop, because Run() returns normally; only the old never-delete workaround is obsolete. The general rule survives the fix: a function that syntactically does not return may still run cleanup machinery, so ownership has to follow the host's actual control flow.
How to test a host-owned loop
A useful regression test cannot stop at "the loop was entered". It must keep the page alive until at least one frame has run, assert inside that frame that the game object and its device are alive, and report through a channel the browser harness can read (a named window flag), because a WebAssembly process exit code is not observable the way a native child's is. It should separately exercise exit and shutdown if those semantics are claimed: at this snapshot that means checking that Exiting, EndRun() and AfterLoop() run and that Run() returns. CNA's lifetime document states the same requirement for its runtime browser checks (several Update()/Draw() frames, Exit(), a return from Run(), normal destruction) and that merely compiling the page does not verify the contract. The minimum diagnostic trace is small and worth logging whenever a web port misbehaves: construction, loop entry, each suspension, first frame, exit request, destruction.
What a successful web build establishes
A completed Emscripten link is real evidence: the selected source closure is accepted by the web toolchain, the C++23 surface compiles with that SDK, and the selected renderers' symbols resolve. It does not establish that runtime content was packaged, that saves survive a reload, that a frame was reached after the browser took control, that generated WebGL shaders compiled in a browser, or that the page displayed the selected renderer rather than merely loading its module.
A bare Node launch is a poor substitute for a browser. SDL-backed renderers need browser host services, WebGL needs a real graphics context and WebGPU needs navigator.gpu in a browser with a WebGPU-capable GPU process, so errors such as "window is not defined" or an SDL initialisation failure under Node say that the wrong host was used, not that a renderer's drawing semantics were tested. Node remains the right host for renderer-independent code, which is how CnaTests.js runs.
A reproducible web evidence record names at least: the CNA commit, the emsdk version (CI pins 6.0.3), the renderer identity or set, the configure options that change the ABI (threads, an Asyncify opt-out), the target, the produced .html, .js and .wasm, every preload mapping, and the host level reached: linked, launched under Node, or served over HTTP to a named browser. Those are ascending levels, not synonyms.
Renderer evidence on the web: four rungs
The browser identities are not equally verified implementations. Four kinds of evidence exist, and each rung includes more of the delivery chain without making the lower tests redundant:
- Build: the renderer compiled and linked to WebAssembly.
- Host start: a JavaScript host loaded the module far enough to run startup.
- Structure: a real browser observed renderer-specific objects and invariants that another renderer could not trivially impersonate.
- Pixels: the browser's compositor produced expected colours at sampled screen positions.
A pixel sample can prove presentation while missing a malformed object tree; a structural assertion can prove the renderer identity while missing blending. The strongest suite does both.
| Identity | Build | Browser run | Structure | Pixels |
|---|---|---|---|---|
WEBGL2 | CI (multi-renderer bundle) | one recorded headless-Chrome run of the renderer benchmark and a 3D demo; no harness in the tree | no | no |
WEBGPU | CI (multi-renderer bundle) | the same recorded run, plus manual headless-Chrome scripts in the tree | no | manual script only: an off-screen 64×64 dump compared with another backend's, not a compositor sample |
'CI' in the Build column means a workflow is configured to build that identity, not a recorded passing run: the web workflow (emscripten-multi-renderer-ci.yml) builds one bundle with WEBGL2 and WEBGPU and inspects it, against the sharp-runtime branch that matches the run's branch, but does not run it in a browser. The table is deliberately asymmetric. A build entry says nothing about frame one, a recorded run is not a repeatable check, and a handful of sampled pixels says nothing about complete rendering parity; what the table gives each claim is a precise meaning, and it shows the shortest path to stronger evidence for each identity.
WEBGL2 and WEBGPU: one CI bundle, browser runs recorded by hand
emscripten-multi-renderer-ci.yml configures WEBGL2;WEBGPU into one bundle, checks with llvm-nm that the JavaScript selection exports cna_set_preferred_renderer and cna_read_module_preferred_renderer are in the core archive, that the EasyGL and WebGPU renderer archives were built, and that the generated registry holds exactly two descriptors; it launches no browser. CNA's renderer-selection document records one run outside CI: in headless Chrome on a real GPU (2026-10-07, emsdk 6.0.9) the renderer benchmark and cna_house3d_demo --depth-probe ran under each of the two, selected at run time through Module.cnaPreferredRenderer (runtime-renderer-selection.md). Nothing in the tree repeats that run for WEBGL2, so context creation, shader compilation, uploads, presentation and context loss have no repeatable browser check for it. In particular, EasyGL's handling of webglcontextlost exists in source but no test deliberately loses and restores a context and then demands a fresh visible frame. WEBGPU additionally has browser routes driven by the manual scripts run-webgpu-browser-test.sh and run-webgpu-parity-test.sh rather than by a workflow (see Tutorial 132).
emcmake configure + build Wasm + JS (+ .data from --preload-file)
(exception ABI, Asyncify, ----> one renderer or a multi-renderer bundle
optional pthread ABI) |
v
served over HTTP (not file://)
|
v
browser page
|
v
Game::Run() on the Wasm stack: init -> frame body -> await rAF -> ...
(Asyncify suspends between frames; Run() returns after exit)
|
v
WEBGL2 (EasyGL) | WEBGPU
|
+------------------+---------------+----------------+
v v v
window verdict flag renderer identity compositor screenshot
(page -> harness) (selected at run time) samples (pixels)
Game::Run() runs on the WebAssembly stack and is suspended between frames while the browser schedules animation frames, then returns after exit. The selected browser renderer draws, and three separate observations can be made: a verdict the page publishes on window, the renderer the runtime reports as selected, and pixels sampled from the compositor's screenshot. Loading a page, reaching a frame, engaging the intended renderer and verifying composited pixels are separate milestones.A common browser-verification shape
All browser identities can converge on one harness pattern, which CNA's browser runner for the C ABI probe already follows in part (it serves the page over loopback HTTP and treats a missing pass summary as a failure): serve the artifacts over loopback HTTP; wait for a named window verdict with a timeout; fail on console errors and page exceptions; assert a renderer-specific structural token; sample a few composited pixels from a real screenshot; and archive the page log and screenshot. Renderer-specific cases then add shader compilation, context restoration, host-element ownership or canvas behaviour. The central rule is to prove that the intended runtime engaged: a green build can be produced without a browser, a green page before any frame, and a plausible image by the wrong rendering route. Identity, execution and visible output are three separate facts, and the evidence should contain all three. The cross-platform framing is on the cross-platform contract.
Running the browser runner by hand: how Playwright is found
CNA's browser runner, run_browser_tests.mjs, serves a built Emscripten test page and reports its verdict; the C ABI's browser probe, the CTest CApi_WasmBrowserProbe, runs through it. It is an ES module, and Node's ES-module resolver ignores NODE_PATH, while Playwright is usually installed globally rather than inside the repository. The runner therefore builds a CommonJS loader with createRequire(import.meta.url) and tries NODE_PATH and two fixed global module directories before it falls back to an ordinary require; it accepts PLAYWRIGHT_CHROMIUM as the path of a replacement Chromium executable. --check-playwright runs no page and only reports whether Playwright resolves, and the C API's CMake uses exactly that answer to decide whether it registers the probe or a skip, so the build and the run cannot disagree about what is resolvable.
Without such a replacement the runner launches Playwright's own headless Chromium. A missing browser download is therefore an environment failure, not a renderer result. Checked by reading at c1c316b9; not executed.
Read in this order
BuildPerformance.cmake: the exception ABI, Asyncify and the per-target opt-out.modules/CMakeLists.txt: whatcna_build_configpublishes to consumers, including the pthread ABI.Game.cpp:Run,RunLoopandEmscriptenMainLoopCallback.StorageDevice.cppandWebStoragePre.js: where saves go and how the browser mounts are restored.run_browser_tests.mjs: how a browser page is served and judged.emscripten-multi-renderer-ci.yml: what the bundle job checks and what it does not run.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Platforms: Web (Emscripten) · Tutorial 81: Emscripten · Tutorial 124: web gotchas · Tutorial 105: browser renderers
- Architecture
- Runtime architecture
- Internals
- One frame source trace: Emscripten
- Tests and validation
- Test architecture
- Deep dives
- The Game class · Network sessions · The cross-platform contract