CNA-BUG-080: The Emscripten Game loop (EmscriptenMainLoopCallback) does not implement Game's loop contract
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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.
In the browser, Game ignores SuppressDraw and ResetElapsedTime, uses FNA's clock order instead of the XNA rule the desktop loop implements, and turns a frame exception into a normal return from Run() without Exiting.
- Identifier
CNA-BUG-080- Category
- Bug
- Subsystem
- Core & runtime
- Status
- Narrowed (partially fixed; describes only what survives)
- Verified against
- CNA
3b43bd86(3b43bd8687ed46523d873f9bec4b32bd8481dcb5) - Severity
- Medium (a triage suggestion, not a project priority)
- Evidence basis
- Source-verified: read at TARGET, not executed
- Tests touching this area
- Yes: see Current tests
- Affected contract
- Microsoft::Xna::Framework::Game::Run() under Emscripten; IsFixedTimeStep, SuppressDraw(), ResetElapsedTime(), GameTime, the Exiting event
Expected behaviour
The same Game contract as the native Game::Tick path: variable-step updates when IsFixedTimeStep is false; SuppressDraw() and ResetElapsedTime() honoured; the XNA clock rule CNA measured and pins on the desktop (first update with zero ElapsedGameTime, TotalGameTime as the time before the step); IsRunningSlowly meaningful; and a frame exception leaving Run() as it does natively (Run() logs and rethrows).
Actual behaviour at TARGET
Game::EmscriptenMainLoopCallback runs its own clock. With IsFixedTimeStep true it keeps a millisecond accumulator: it caps the delta at 250 ms, drains whole TargetElapsedTime steps, gives the first update a full step and adds each step to TotalGameTime before Update. With IsFixedTimeStep false it runs one Update per frame with the measured time (capped at 250 ms), again adding it to TotalGameTime first. In both modes it forces IsRunningSlowly to false, draws only if an update ran, and never reads suppressDraw_, forceElapsedTimeToZero_ or isSuspended_. Its catch blocks log a frame exception, clear the loop slot and return, so RunLoop ends, Run() calls EndRun() and AfterLoop() and returns normally, and Exiting is never raised.
Source locations
modules/runtime/src/Game.cpp— Game::EmscriptenMainLoopCallback and the __EMSCRIPTEN__ branch of Game::RunLoop; compare Game::Tick and Game::Runmodules/runtime/tests/Microsoft/Xna/Framework/GameClockFirstUpdateTests.cpp— the XNA clock rule pinned for the native path
Evidence
Checked by reading both loop bodies in Game.cpp at 3b43bd86. The native rule is pinned by GameClockFirstUpdateTests.cpp, whose header records values measured on XNA 4.0. No CNA document describes the browser loop's different contract; the libcna.com guide warns about it. Not executed, and no browser build was run.
Independently observed as a separate finding (merged): EmscriptenMainLoopCallback catches every exception from a frame, logs it and clears the loop slot, so RunLoop ends, Run calls EndRun/AfterLoop and returns, and the game's caller cannot observe the failure.
Independently observed as a separate finding (merged): The browser frame callback keeps its own accumulator, caps a frame at 250 ms, always drains fixed steps and never reads isSuspended_, suppressDraw_ or forceElapsedTimeToZero_, so pause/background handling and slow-frame semantics differ from the native loop.
Narrowed at c6dec91f: 90ae88759 (2026-10-15) gave the browser loop a variable-step branch: with IsFixedTimeStep false it now runs one Update and one Draw per frame with the measured elapsed time instead of draining fixed steps. The other differences are unchanged; f9747ea49 (2026-10-23) changed ResetElapsedTime for the native loop only.
Focused reproduction
No focused reproduction is known. Nothing has been invented here; the evidence above is what exists.
Current tests
The clock tests, including the new ResetElapsedTimeTests.cpp, run on the native loop only. The web examples exercise the browser loop end to end but assert no clock values; nothing tests variable-step timing, SuppressDraw() or frame-exception propagation under Emscripten.
Regression test
Factor the frame body so that its timing decisions can run without a browser, then run GameClockFirstUpdateTests' and ResetElapsedTimeTests' scenarios and a SuppressDraw() scenario against it; add a browser smoke test that throws from Update and expects Run() to propagate it.
Blast radius
Every game built for the web target: state that accumulates per fixed-step update is one step ahead of the desktop build, SuppressDraw() and ResetElapsedTime() have no effect, IsRunningSlowly is always false, and a crash in a frame looks to main() like a normal exit. Native targets are unaffected.
Workaround
For code that must behave identically on desktop and web, do not depend on the zero first fixed-step update, on IsRunningSlowly, on SuppressDraw() or on ResetElapsedTime(); subscribe to Exiting for cleanup only on the desktop and check the log for fatal frame errors on the web.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.