The Game class: contract, run modes and extension points

CNA snapshot 009d40f5  ·  Deep Dives › Framework core  ·  source links pinned to 009d40f5

✓

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 009d40f5; unless a sentence says otherwise, nothing here was built or executed. The browser loop was read, not run; XNA 4.0 comparisons come from a disassembly of the XNA 4.0 Game assembly, not from an oracle run. No test asserts the relative order of Exiting, EndRun and AfterLoop, nor the Exit-before-Run and second-Run edges.

Every CNA program derives from Microsoft::Xna::Framework::Game and hands it control with Run(). This page is the precise contract of that base class for people porting XNA or FNA code and for anyone who drives the loop themselves: what the events are typed as, which properties validate or silently store, what each override point's base implementation does, how Run(), RunOneFrame() and Tick() differ at their edges, what the browser loop changes, and which exceptions can reach main(). The function-by-function source traces are the startup and one-frame internals pages; this page stays at the level of observable semantics.

A base class, not an entry point

CNA has no engine-owned main(). The game executable constructs its derived Game and calls Run(); Game is declared in Game.hpp as class Game : public System::Object, public System::IDisposable, and its own brief describes it as the base class that provides the XNA-style game loop, services, window, content and components. The shape is XNA's Program.Main on purpose, so that an XNA entry point ports almost mechanically.

What already exists when the derived constructor runs

The base constructor (Game.cpp, Game::Game) finishes before the derived constructor body starts, and by then it has installed the game's platform as the ambient one, constructed a real GraphicsDevice value member (renderer resolved, window created for renderers that need one, default GraphicsProfile::Reach and default presentation parameters), bound the window and content manager to that device, registered every built-in XNB reader and called FrameworkDispatcher::Update() once. A GraphicsDeviceManager constructed in the derived constructor therefore does not create a device; it registers itself as a service and its preferences are applied later, by CreateDevice() inside DoInitialize, as an in-place reset of the existing device. The consequence for game code: calling getGraphicsDeviceProperty() in a derived constructor is legal and returns a live device, but it is the pre-preference device, so GPU resources and content still belong in LoadContent(). The full construction order is on Startup source trace.

Four events, all typed EventArgs

Game declares four public events: Activated, Deactivated, Disposed and Exiting, all of type System::EventHandler<System::EventArgs>. Each is raised with the game itself as sender; OnExiting, OnActivated and OnDeactivated ignore the sender argument they are given and raise with this.

The Exiting type deserves a note. CNA also ships an empty ExitingEventArgs class (ExitingEventArgs.hpp), but Game::Exiting is not declared with it, nothing in the runtime constructs one, and its only tests are that it is default-constructible and derives from System::EventArgs. That declaration is the XNA-faithful one: the XNA 4.0 Microsoft.Xna.Framework.Game assembly declares Game.Exiting as EventHandler<EventArgs>, and no ExitingEventArgs type exists in the XNA 4.0 assemblies at all (checked against a disassembly of those assemblies, not a CNA file). So the dedicated type is a CNA-specific addition, which is how CNA's own AUDIT.md lists it, not an XNA type that CNA declines to use. Handlers are written as [](System::Object* sender, const System::EventArgs& e); a handler written against ExitingEventArgs does not compile (checked with g++ -fsyntax-only: no matching operator+=). The C API header runtime.h draws the same conclusion for its event callbacks: nothing is lost by exposing only the sender. Exiting is a notification; there is no cancellation argument.

Properties, their defaults and their guards

Properties follow the project-wide getXProperty() / setXProperty() convention (language conventions). The table lists what the accessors actually do at this snapshot, including the ones that store a value without acting on it.

PropertyAccessDefaultBehaviour worth knowing
ComponentsgetemptyA GameComponentCollection member; see components and services.
Contentget / setroot ContentsetContentProperty(const ContentManager&) copy-assigns into the game's own member; the game never switches to a different object.
GraphicsDeviceget onlythe game's own deviceReturns the registered IGraphicsDeviceService's device when one is registered and has a device, otherwise the game's own member. The service pointer is looked up once and cached. There is no setter.
InactiveSleepTimeget / set20 msA negative value throws std::out_of_range (the message says "must be positive", but zero is accepted). The value is stored and read by nothing in the loop: an unfocused CNA game keeps its normal rate. XNA 4.0's Tick sleeps for this span while the game is inactive.
IsActiveget onlyfalseSee below.
IsFixedTimeStepget / settrueRead on every tick; switching it mid-game takes effect on the next tick. Clock details: GameTime and the timestep.
IsMouseVisibleget / setfalseSetting the current value is a no-op. A change reaches the platform mouse service only when a platform window exists and the platform has a mouse service; otherwise the value is only recorded.
LaunchParametersget (mutable)parsed from the process command lineEmpty on platforms where CNA cannot read the command line; see supporting types.
TargetElapsedTimeget / set166,667 ticksZero or negative throws std::out_of_range. The default is 16.6667 ms, the nearest tick count to 1/60 s.
ServicesgetemptyThe GameServiceContainer; it owns nothing.
Windowgetbound to the device's windowA facade over the platform window the device created or adopted.

IsActive has no public setter

FNA declares IsActive with an internal set, so only the framework may change it. C++ has no assembly-internal visibility, so CNA makes setIsActiveProperty private: it returns early when the value does not change and otherwise raises Activated or Deactivated through the protected virtuals. Only Game itself calls it: BeforeLoop() (true, once per Run()), focus-gained and focus-lost window events, and on mobile targets the background and foreground lifecycle events. OnActivated and OnDeactivated call AssertNotDisposed(), so a focus change delivered to a disposed game throws std::runtime_error; OnExiting has no such check.

The CNA extensions on Game

Members marked CNAEXT are not XNA 4.0 API (with CNA_STRICT_XNA_API the marker becomes [[deprecated]], see core module internals):

  • RunApplication — a public bool, documented as the internal loop flag matching the FNA/XNA implementation shape. It is the desktop loop condition, Exit() clears it, and nothing but application code ever sets it back to true.
  • getTargetFPSProperty(), getTargetMsFrameTimeProperty() and the static fpsToMillisecondsPerFrame(intcs) — compatibility helpers kept for existing CNA examples that talk about frame timing in frames per second. They are derived from TargetElapsedTime, so with the default of 166,667 ticks getTargetFPSProperty() returns about 59.99988, not 60. fpsToMillisecondsPerFrame returns 0 for a non-positive argument instead of throwing.
  • GetPlatformEXT() and GetPlatformCapabilitiesEXT() — the platform the game owns and the capability set cached once at construction (capability discovery may probe a terminal, so it must not become a per-frame query).
  • The protected constructor Game(std::unique_ptr<CNA::Platform::IPlatform>) for embedding hosts and tests; a null platform throws std::invalid_argument.

Override points and what the base implementation does

A derived game overrides protected virtuals. Several base implementations do real framework work, so whether an override calls the base is part of the contract, not style.

VirtualCalled fromBase implementationAn override that skips the base
Initialize()DoInitialize, onceInitializes current components by index (re-reading the count, so a component added by another's Initialize is initialized too), caches the IGraphicsDeviceService, subscribes UnloadContent to its DeviceDisposing, then calls LoadContent() — or defers it to DeviceCreated when the service has no device yetComponents are never initialized, LoadContent() is never called and the UnloadContent route does not exist
LoadContent() / UnloadContent()base Initialize() / the service's DeviceDisposingEmptyNothing lost
Update(GameTime&)Tick, once per stepUpdates enabled components in UpdateOrder, then FrameworkDispatcher::Update()No component updates, and no dynamic-sound buffers, microphone checks, media-player transitions or touch-panel updates
Draw(const GameTime&)Tick, after the updates, when BeginDraw() is trueDraws visible components in DrawOrderNo DrawableGameComponent is drawn
BeginDraw()TickThe registered manager's BeginDraw(), or true without oneReturning false skips both Draw and EndDraw for that tick
EndDraw()Tick, after DrawThe manager's EndDraw() (which presents), or GraphicsDevice::Present() without a managerNothing is presented
BeginRun() / EndRun()Run() onlyEmptyNothing lost; never called under RunOneFrame()
OnExiting, OnActivated, OnDeactivatedthe loop and window eventsRaise Exiting, Activated, DeactivatedThe corresponding event is never raised
ShowMissingRequirementMessage(const std::exception&)nothing at this snapshotReturns falseOverriding it has no effect
Dispose(bool)public Dispose() (true) and the destructor (false)See Exit, Exiting, Dispose and destructionComponents, content and the device service are not disposed

Two rows need a comment. The safe shape for Update is game work plus exactly one base call per step; a port that deliberately replaces the component loop must still call FrameworkDispatcher::Update() itself once per step, and calling both the base and the dispatcher pumps those services twice. ShowMissingRequirementMessage is inert: no CNA code calls it. In the XNA 4.0 assembly, Game.Run (through its private RunGame) catches NoSuitableGraphicsDeviceException and NoAudioHardwareException, offers them to ShowMissingRequirementMessage and rethrows when it returns false; in CNA those failures surface as ordinary exceptions from the constructor or from Run(), so a port that relied on the hook to show a friendly message must catch in main() instead.

Run(), RunOneFrame() and Tick()

For an ordinary first desktop Run() the source gives this order:

  1. AssertNotDisposed(), then DoInitialize() if the game has not been initialized: look up the registered IGraphicsDeviceManager and call its CreateDevice(), call Initialize(), sort the current components into the update and draw lists, subscribe to ComponentAdded / ComponentRemoved.
  2. BeginRun(), then BeforeLoop(), which sets IsActive and so raises Activated the first time.
  3. The performance counter is seeded and RunLoop() calls Tick() while RunApplication is true. Exit(), a quit event, a window close request or a mobile Terminating event clears it.
  4. After the loop, RunLoop() calls OnExiting(), which raises Exiting.
  5. Only then does Run() call EndRun() and AfterLoop() and return. Nothing is disposed.

The whole sequence sits in one try: an exception from any step is logged through CNA::Logger::Error ("fatal exception escaped Game::Run()") and rethrown unchanged.

Edges of the run sequence

The first two edges describe the desktop loop; in a browser the frame body always runs at least once before it observes the cleared flag.

  • Exit() before Run() does not bypass the lifecycle. Initialization, BeginRun and BeforeLoop still run, the loop performs zero ticks, Exiting is raised, and EndRun and AfterLoop follow.
  • A second Run() after the first returned does not reset RunApplication. Initialization is skipped (it happened once), BeginRun and BeforeLoop run again (IsActive is already true, so no second Activated), the loop performs zero ticks and Exiting is raised a second time. Setting the public RunApplication back to true first makes the second Run() tick normally. After Dispose(), Run() throws std::runtime_error.
  • RunOneFrame() performs the one-time initialization and seeds the counter on its first call, then one Tick(). It never calls BeginRun, BeforeLoop, OnExiting, EndRun or AfterLoop. Because only BeforeLoop activates the game, IsActive stays false under RunOneFrame() until a focus or lifecycle event arrives — an easy trap for editor hosts and tests. Exit() inside such a host only clears RunApplication and suppresses the current draw; the host has to read the flag itself.
  • Tick() is public. Calling it directly without Run() or RunOneFrame() skips initialization entirely: no CreateDevice, no Initialize, no LoadContent, empty component lists. The first Tick() also finds an unseeded counter, so its clock read only seeds it, and in fixed-step mode that tick then waits a full target span before updating.
  • Run() after RunOneFrame() does not initialize again; it runs the begin hooks, reseeds the counter and enters the loop.

Evidence: GameTest.RunExecutesLifecycleInDocumentedOrder in GameTests.cpp asserts one Initialize, one LoadContent and at least one Update and Draw; despite its name it does not assert the relative order of Exiting, EndRun and AfterLoop, and no test covers Exit() before Run() or a second Run(). The order above is read from the source; none of these tests was executed for this page.

The browser run loop

Under Emscripten the public methods are the same but the loop is a different body. RunLoop() stores the game in a static s_emLoopState and repeatedly calls EmscriptenMainLoopCallback(), suspending the same Wasm stack between frames with an EM_ASYNC_JS function that awaits requestAnimationFrame (Asyncify). Run therefore blocks and returns as on the desktop, which is why a stack-allocated game is valid on the web (emscripten-mainloop-game-lifetime.md records the earlier non-returning registration and why it was replaced). The lifecycle consequences:

  • The frame body raises Exiting itself when it observes RunApplication == false and clears the loop pointer; RunLoop() then returns and Run() calls EndRun() and AfterLoop(). Older descriptions that say the post-loop hooks never run in a browser describe the replaced implementation.
  • An exception thrown by a frame is caught in the frame body, logged ("fatal exception in Emscripten main loop") and ends the loop — and Exiting is not raised. Run() then calls EndRun() and AfterLoop() and returns normally, so on the web a frame exception does not propagate to main().
  • The loop state is one static slot: one Game runs its loop per process. Destroying the game that owns the loop while Run() is active clears the slot and logs an error so the loop stops before the game's resources go.
  • Timing inside the frame body differs from the desktop tick in every detail (clock source, step policy, first update, IsRunningSlowly, draw cadence); One frame source trace tabulates it and GameTime and the timestep lists what a game can observe.
                  Game::Run()
                       |
          AssertNotDisposed, DoInitialize (once)
          CreateDevice -> Initialize -> LoadContent
                       |
               BeginRun -> BeforeLoop (IsActive = true)
                       |
        +--------------+-------------------------------+
        | desktop                                      | Emscripten (Asyncify)
        v                                              v
  while (RunApplication)                   while (loop slot == this)
    Tick: wait, PollEvents,                  frame body: PollEvents, clock,
    Update x steps, Draw, EndDraw            Update x steps, Draw if updated
        |                                    await requestAnimationFrame
        v                                              |
  OnExiting (Exiting raised)              RunApplication false -> Exiting
        |                                 frame exception -> logged, no Exiting
        +--------------+-------------------------------+
                       v
               EndRun -> AfterLoop -> Run() returns
               (nothing disposed; Dispose() is explicit)
Figure. Control flow of Game::Run() at this snapshot. Both targets share initialization, BeginRun and BeforeLoop, then split: the desktop loop ticks while RunApplication is true and raises Exiting after the loop; the browser loop runs a separate frame body between requestAnimationFrame suspensions and raises Exiting from inside that body, except after a frame exception. Both paths rejoin at EndRun and AfterLoop, and Run() returns without disposing anything.

F9 and F10 are reserved in every game

Game::PollEvents passes every native event to the input bridge first and then reacts to it. A non-repeated press of F9 calls the renderer's DebugSimulateContextLoss() and F10 calls DebugRestoreContext(), with no build guard. Because input saw the key first, a game that binds F9 or F10 gets its own key state and the framework action. The default hooks in IGraphicsRenderer are empty; at this snapshot the EasyGL, DirectX 9, DirectX 11, DirectX 12, Direct2D and WebGPU families declare non-empty overrides (GDI overrides both with empty bodies), so on those renderers the keys rehearse a loss and a recovery, with two consequences that are easy to miss. On DirectX 11, DirectX 12 and WebGPU a lone F9 marks the device lost and, with a registered GraphicsDeviceManager, BeginDraw() then refuses every frame until F10 restores it, so the game keeps updating but stops drawing. On WebGPU, F9 also throws System::NotSupportedException while a TextureCube, RenderTargetCube, Texture3D, occlusion query or custom ShaderEffect is alive, and nothing in PollEvents, Tick or Run catches it: natively it propagates out of Run() after the logged “fatal exception” line, and in the browser loop it is logged and the loop stops without raising Exiting (CNA-BUG-078). XNA's Game reserves no keys at all. What each family does is renderer-specific; the event table is on One frame source trace and the debugging advice on Debugging.

One catch clause at the framework boundary

The runtime module does not translate failures into an XNA exception hierarchy. Its sources and headers contain 21 explicit throw sites at this snapshot, all standard types: std::out_of_range (the two Game property guards, collection indices), std::invalid_argument (null platform, duplicate component, null or duplicate service, null game or second manager), std::runtime_error (use after dispose, CreateDevice without a game-owned device, TitleContainer failures), std::logic_error (the unreachable collection SetItem) and std::bad_alloc (TitleContainer buffers). Use after disposal is therefore a plain std::runtime_error ("The Game object was used after being disposed."), not a dedicated disposed-object type.

Calls into other modules add two families. One derives from std::runtime_error: CNA::Platform::PlatformException (for example a failed window setter on the SDL3 platform), ContentLoadException, the XML and JSON parse exceptions and XnbWriteException. The other derives from Sharp Runtime's System::Exception: the graphics device exceptions (NoSuitableGraphicsDeviceException, DeviceLostException, DeviceNotResetException), CNAException, microphone, gamer-services, network, sensor, engine-layer and pipeline exceptions. System::Exception derives from std::exception, not from std::runtime_error (sharp-runtime next @ 41b918c9, System/Exception.hpp). Consequently catch (const std::runtime_error&) misses the whole System::* family and catch (const System::Exception&) misses every standard and platform failure; only catch (const std::exception&) spans both.

int main()
{
    try {
        MyGame game;          // renderer or platform failures surface here
        game.Run();           // Run() logs, then rethrows
        game.Dispose();
    } catch (const std::exception& e) {
        // log-and-exit boundary: catch narrower types only where recovery differs
        std::fprintf(stderr, "fatal: %s\n", e.what());
        return 1;
    }
    return 0;
}

Catch narrowly where recovery depends on the exact contract (a ContentLoadException for a missing optional asset, for example) and use std::exception only at the top-level boundary. On the web, remember that a frame exception never reaches this handler (above).

What this means for a port

  • A game class that uses only the public XNA surface of Game ports by translating syntax: the same virtuals, the same event names, the same timing and component properties under get…Property() spellings. The names carry over; the behaviour is not guaranteed to be identical, and the differences listed next are on that public surface.
  • Public members whose behaviour differs from XNA 4.0 need a second look: ShowMissingRequirementMessage is never called, InactiveSleepTime is not applied, ResetElapsedTime() does nothing under the fixed step, IsRunningSlowly follows a different rule (GameTime and the timestep), a DrawableGameComponent loads once and does not follow the device, disposing a GameComponent does not remove it from Components (components). Also check IsActive under RunOneFrame() (false) and the browser loop, which swallows frame exceptions.
  • Keep the base calls in Initialize, Update and Draw; never call Present() from Draw (the manager presents in EndDraw); call Dispose() after Run() when UnloadContent() must run.

Evidence and limits

Everything above was read from Game.cpp, Game.hpp, the renderer headers under modules/renderers and the exception headers of each module at this snapshot. The runtime tests named here exist and were not executed; most of them skip when the configured platform cannot create a window. The browser loop was read, not built or run. XNA 4.0 statements come from a disassembly of the XNA 4.0 Microsoft.Xna.Framework.Game assembly and describe XNA, not CNA; no oracle run compares these lifecycle edges. The main() example was syntax-checked with g++ -std=c++23 -fsyntax-only against the TARGET headers as part of a complete translation unit; it was not built or run.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Architecture
Runtime lifecycle
Maintainer workflow
Change public XNA behavior
Tests and validation
Test architecture