GameTime and the timestep: exact clock semantics

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 tick traces are arithmetic on the source, not measurements; IsRunningSlowly and ResetElapsedTime have no tests, and the XNA 4.0 comparisons come from a disassembly of the XNA 4.0 Game assembly rather than an oracle run.

This page explains exactly what a CNA game's clock reports and why: how GameTime is protected, how the fixed-step accumulator turns a long frame into several updates and one draw (with real numbers), how IsRunningSlowly is decided and how that differs from XNA 4.0, when ResetElapsedTime() does nothing, why vertical sync and the fixed timestep are separate switches, and what changes in a browser. It is for game authors who tune timing-sensitive code and for anyone porting an XNA game whose simulation depends on the clock. The desktop tick is traced function by function on One frame source trace; the user-level introduction is fixed vs variable timestep and Tutorial 48.

GameTime: three read-only fields and one writer

GameTime.hpp holds TotalGameTime and ElapsedGameTime (both System::TimeSpan) and IsRunningSlowly (bool). The three getters are public; the three setters are private, and the class declares friend class Game, so only Game can change an individual field. That mirrors XNA 4.0, where the setters are internal to the framework assembly. Three public constructors (all-zero; total and elapsed; total, elapsed and the slow flag) let tests and tools build any value.

The protection is per field, not per object. The implicitly declared copy assignment is public, and Update(GameTime&) receives Game's own clock object by non-const reference — the same object every component's Update receives. A statement such as gameTime = GameTime(TimeSpan::Zero, TimeSpan::Zero); inside an update therefore compiles (checked with g++ -fsyntax-only) and overwrites the state Tick continues from: after the update returns, TotalGameTime is advanced from whatever the object now holds. In C# the same line only rebinds a local parameter, so an XNA port that "resets" its gameTime argument changes behaviour in C++. Treat the parameter as read-only. Draw receives a const GameTime& and cannot do this.

The fixed-step accumulator

With IsFixedTimeStep true (the default) each desktop Game::Tick in Game.cpp does, in order: add the elapsed performance-counter time to an accumulator; wait (a 1 ms platform delay while the accumulator plus the estimated worst-case sleep precision is short of the target, then std::this_thread::yield() until it reaches it); poll events; clamp the accumulator to MaxElapsedTime (500 ms); then, while the accumulator holds at least one TargetElapsedTime, run one update per whole target span; finally draw once. The waiting machinery — a 128-entry ring of measured sleeps, masked with SLEEP_TIME_MASK, each sample capped at 4 ms — is FNA's adaptive sleep-precision loop carried over, with yield() standing in for Thread.SpinWait(1).

The numbers that matter:

  • The default TargetElapsedTime is 166,667 ticks: 16.6667 ms, the nearest 100 ns tick count to 1/60 s.
  • Time comparisons in the loop go through double milliseconds, and each counter delta is converted with TimeSpan::FromMilliseconds, which truncates to whole ticks.
  • Because 30 steps of 166,667 ticks are 5,000,010 ticks, 10 ticks (1 µs) more than the 5,000,000 ticks of the 500 ms clamp, the clamp allows at most 29 updates in one tick at the default target.
  • The very first update the game ever runs sees a zero ElapsedGameTime, and TotalGameTime is always the time before the step, advancing only after Update returns. Both rules were measured against XNA 4.0 and adopted over FNA's behaviour; GameClockFirstUpdateTests.cpp records the measurement and pins it.

A long frame, with real numbers

Suppose a texture upload stalls one frame, and the next tick finds 50.5 ms in the accumulator (a 50 ms hitch plus 0.5 ms left over from earlier ticks). The update loop runs while the accumulator is at least 16.6667 ms:

StepAccumulator beforeElapsedGameTime seen by UpdateAccumulator after
150.5000 ms16.6667 ms33.8333 ms
233.8333 ms16.6667 ms17.1666 ms
317.1666 ms16.6667 ms0.4999 ms (carried to the next tick)

Three updates run back to back, each told that exactly one target step elapsed. Immediately before drawing, the loop rewrites ElapsedGameTime to TargetElapsedTime × stepCount, computed in integer ticks (FNA's TimeSpan.FromTicks(TargetElapsedTime.Ticks * stepCount)): the single Draw of this tick sees 500,001 ticks, 50.0001 ms, and a TotalGameTime that already includes all three steps. The update/draw asymmetry is intentional catch-up accounting, not an unstable clock, and it has two practical consequences:

  • Update must be safe to run several times between two draws and must never assume one update per draw. Code that samples input edges, spawns once per frame or measures "frames" inside Update sees every catch-up step.
  • A frame counter belongs in Draw. An FPS counter that counts calls to Update under the fixed step reports the update rate, which stays at the target even when drawing slows down; summing the ElapsedGameTime seen by Draw gives the game time the drawn frames covered (a counter component built that way).

Had the accumulator held exactly 50.0 ms, only two steps would run (three would need 50.0001 ms) and 16.6666 ms would carry over. A TargetElapsedTime above 500 ms is a degenerate setting: the wait loops accumulate more than the clamp allows, so no step ever runs and every tick draws; see One frame source trace.

Variable step

With IsFixedTimeStep false there is no waiting. The tick polls events, clamps the accumulator to 500 ms, passes the whole accumulator to exactly one Update (zero only when ResetElapsedTime() asked for it), clears the accumulator and draws. The zero-first-update rule is applied on the fixed-step path only: in variable step the first update receives the short interval since Run() seeded the counter. The comment on VariableStepTotalAlsoLagsItsOwnUpdate records XNA reporting a total of zero at its third variable-step update, which implies zero elapsed time for its first two; CNA's test asserts only that each total is the sum of the previous elapsed times. IsRunningSlowly is never written on this path, so it keeps whatever value the fixed-step path last gave it — false for a game that starts in variable mode, but possibly true forever for a game that switches to variable step while running slowly.

IsRunningSlowly: a lag counter with hysteresis

After the update loop of a fixed-step tick, Game::Tick updates a private updateFrameLag_ counter that persists across ticks:

updateFrameLag_ += std::max(0, stepCount - 1);   // extra steps this tick

if (gameTime_.getIsRunningSlowlyProperty()) {
    if (updateFrameLag_ == 0) gameTime_.setIsRunningSlowlyProperty(false);
} else if (updateFrameLag_ >= 5) {
    gameTime_.setIsRunningSlowlyProperty(true);
}

if (stepCount == 1 && updateFrameLag_ > 0) --updateFrameLag_;   // recover one per normal tick

Three properties follow directly from that order. The flag is written after the tick's updates, so the updates of the tick that crossed the threshold do not see it; that tick's Draw and the next tick's updates do. The flag turns on when the accumulated extra steps reach five, and it turns off only when the counter is back at zero, which takes one more normal tick than the counter's value because the check runs before the decrement. And the threshold is on accumulated extra steps, not on consecutive slow ticks, so one very long tick can set it by itself.

Three traces at the default 60 Hz target

ScenarioSteps per tickupdateFrameLag_ after each tickIsRunningSlowly
One 50 ms stutter3, then 1, 12, 1, 0never set
Sustained overload2, 2, 2, 2, 2, then 1 × 61, 2, 3, 4, 5, then 4, 3, 2, 1, 0, 0set on the fifth 2-step tick; cleared on the sixth normal tick
One stall of about 100 ms or more (six steps need 100.0002 ms)6 or more (29 at most after the clamp), then 1 …5 or more, then counting down by oneset on that single tick; after a 500 ms stall it stays set for 28 normal ticks and clears on the 29th

So a game that uses IsRunningSlowly to drop optional effects reads a smoothed signal that lags the frame time in both directions, and a single synchronous load of half a second inside Update makes it report "slow" for about the next half second of normal frames.

Not XNA 4.0's heuristic

The counter belongs to the clock loop CNA carried over from FNA (the loop's own comments cite FNA for the spin wait, the stopwatch substitute and the integer-tick draw time). XNA 4.0 decides differently: its Game.Tick (read from a disassembly of the XNA 4.0 Microsoft.Xna.Framework.Game assembly) keeps two counters of ticks since the last two multi-step ticks, reports running slowly while the older of those two lies fewer than 20 ticks back, and assigns the flag before each Update of the tick. Under XNA a single multi-step tick, however long, never sets the flag; under CNA a tick with six or more steps does, while two 2-step ticks fewer than 20 ticks apart set it under XNA but not under CNA. The first-update and total-time rules of CNA's clock were measured against XNA 4.0; IsRunningSlowly was not, and no test pins the hysteresis at all: GameTimeTests.cpp covers only the constructors and getters, and the timing tests drive single frames. Code whose behaviour must match XNA frame for frame should not depend on the exact tick at which the flag changes.

ResetElapsedTime() does nothing under the fixed step

void Game::ResetElapsedTime()
{
    if (!IsFixedTimeStep_)
    {
        forceElapsedTimeToZero_ = true;
    }
}

In variable-step mode the next update sees a zero ElapsedGameTime, the accumulator is cleared, and TotalGameTime does not advance for that step. In fixed-step mode — the default, and the mode of most games — the call sets nothing and nothing in the fixed-step branch would read the flag anyway. A game that calls it after a blocking load inside Update still runs the catch-up burst on the next tick (up to 29 updates for a stall of 500 ms or more).

This is a divergence from XNA 4.0, but a smaller one than it looks. In the XNA 4.0 assembly ResetElapsedTime() sets its zero-elapsed flag in both modes and also clears the running-slowly state (drawRunningSlowly false, both hysteresis counters at int.MaxValue), and Tick applies the flag to the measured elapsed time before it branches into fixed or variable step. In fixed-step mode, however, that flagged tick then finds no whole step in the accumulator and returns before GameClock.AdvanceFrameTime(), so the clock’s last-frame time is not advanced and the load’s elapsed time reappears on the following tick, where the same 500 ms clamp applies. XNA therefore defers the catch-up burst by one tick in fixed-step mode instead of removing it (read from the decompiled Game.Tick and GameClock; not executed). CNA runs the burst immediately (up to 29 updates); XNA runs it one tick later (about 30). The remaining difference is that CNA leaves the running-slowly counters alone. The only internal path that clears the fixed-step backlog is the mobile foreground handler, which zeroes the performance counter and the accumulator directly before calling ResetElapsedTime(). On the web the call has no effect in either mode (below).

Vertical sync is a presentation preference, not the timestep

GraphicsDeviceManager::SynchronizeWithVerticalRetrace defaults to true. When the manager prepares device settings it maps the Boolean to PresentationParameters.PresentationInterval — One for true, Immediate for false — and the device's Reset() forwards 1 or 0 to the renderer's SetSwapInterval. This is independent of Game::IsFixedTimeStep: the fixed step decides how many updates the accumulator schedules and how long a tick waits; the swap interval decides whether Present() blocks for the display. Turning one off does not turn the other off.

IsFixedTimeStepVertical syncWhat the loop does
true (default)on (default)Updates at the target rate; a tick waits for a full step, then presentation may also wait for the display.
trueoffUpdates and draws paced by the fixed-step wait alone: at most one draw per target span.
falseonOne update and one draw per tick, paced by the display.
falseoffNo pacing at all: the loop runs as fast as update, draw and present allow.

Asking for PresentInterval::Two

XNA's third choice, half-refresh presentation, cannot be expressed through the Boolean property. The manager raises PreparingDeviceSettings as the last step of building the settings, after it has copied the preferences, on both the CreateDevice() and the ApplyChanges() path, and the handler's changes flow into the reset. Because System::EventHandler hands the argument over as a const reference, CNA adds the CNAEXT accessor getGraphicsDeviceInformationEXT() to reach the settings mutably:

graphics_.PreparingDeviceSettings +=
    [](System::Object*, const PreparingDeviceSettingsEventArgs& e)
    {
        e.getGraphicsDeviceInformationEXT().getPresentationParametersProperty()
            .setPresentationIntervalProperty(PresentInterval::Two);
    };

The device maps Two to a swap interval of 2 and passes it to the renderer unchanged; whether it means half-refresh presentation is renderer-specific, and on several families CNA itself decides rather than the driver: Vulkan maps 2 to FIFO_RELAXED, which is not half-rate presentation, and Metal treats any nonzero interval as ordinary display sync. A game that needs 30 Hz pacing should therefore not rely on Two; use TargetElapsedTime. The family-by-family results are tabulated on Presentation modes, swap interval, native handles and back-buffer readback; check the target renderer. How each renderer applies a changed interval is on its internals page — for example the Vulkan renderer records the request and rebuilds its swapchain (Vulkan internals). The manager's own SynchronizeWithVerticalRetrace property keeps reporting the Boolean that was requested; it is not an observation of the active present mode.

What changes in a browser

The Emscripten frame body keeps its own clock and ignores most of the above (full comparison). What a game can observe:

  • Time comes from the platform's millisecond tick count, the per-frame delta is capped at 250 ms, and nothing sleeps or yields: requestAnimationFrame paces the loop.
  • Every frame drains whole TargetElapsedTime steps; IsFixedTimeStep = false is not honoured.
  • The first update gets a full step, and TotalGameTime advances before Update — the XNA clock rules above hold on the desktop path only.
  • IsRunningSlowly is set to false before every update.
  • A frame draws only if at least one update ran; SuppressDraw() and ResetElapsedTime() have no effect, because the frame body never consults either flag.

Code that must behave identically on desktop and web should not depend on the zero first update, on variable-step elapsed times or on IsRunningSlowly.

Evidence and limits

The clock semantics were read from Game::Tick, AdvanceElapsedTime, UpdateEstimatedSleepPrecision, ResetElapsedTime and EmscriptenMainLoopCallback in Game.cpp, the manager's settings path in GraphicsDeviceManager.cpp and GraphicsDevice::Reset in GraphicsDevice.cpp, all at this snapshot; the TimeSpan conversion was read in Sharp Runtime (next @ 41b918c9). The traces in the tables are arithmetic on that code, not measurements. GameClockFirstUpdateTests.cpp pins the first-update and total-time rules (its header records the values measured on XNA 4.0 under Wine); GamePlatformTimingTests.cpp checks the platform counter and drives single fixed and variable frames. Neither was executed for this page, and nothing tests the IsRunningSlowly hysteresis, the 29-step clamp consequence or ResetElapsedTime(). XNA 4.0 behaviour is quoted from a disassembly of its assembly, not from an oracle comparison. The PreparingDeviceSettings handler was syntax-checked with g++ -std=c++23 -fsyntax-only against the TARGET headers inside a complete game class; it was not built or run.

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

Tests and validation
Test architecture