The Game Loop & Time
The loop that drives the whole engine looks like three lines and hides a decade of bug reports. Tie the simulation to frame rate and the game runs in slow motion on a weak machine and explodes on a fast one. The fix is a with an , and a render that interpolates between simulation states. We build it from the naive version up, and stop at every failure that forced the next change.
01The loop and the kinds of time
A game is a loop: read input, advance the simulation, draw a frame, repeat. The whole subject of this tutorial is the middle step, advancing the simulation, and specifically how much time each advance represents. Get that wrong and everything downstream (physics, animation, networking) inherits the error.
It helps to separate the kinds of time an engine juggles[1]. Real time is the wall clock. Simulation time is how far the game world has advanced, which can run slower than real time (a hitch), faster (fast-forward), or stop (pause) without the program freezing. Local time is per-system, like the playhead of one animation clip. This tutorial is about keeping simulation time correct and stable as real time does whatever the hardware makes it do.
A fixed-timestep loop with an accumulator and render interpolation, written in C++ and Rust with matching names; a precise account of why variable-timestep loops are non-deterministic and unstable; the and the two guards against it; and the difference between that half the canonical articles get wrong in their naming.
02The naive loop
The first loop everyone writes advances the world by a fixed amount of work per iteration, with no notion of elapsed time:
while (running) {
pollInput();
update(); // advances by a fixed step of WORK, not time
render();
}
while running {
poll_input();
update(); // advances by a fixed step of WORK, not time
render();
}
The simulation speed equals the loop's iteration rate, which is set by the hardware and the current load[2]. A faster CPU runs the game faster; a slower one runs it in slow motion. This is the bug behind old PCs where games became unplayable on newer machines.
Capping the loop with vsync bounds the upper rate, so the coupling stops being visible on hardware fast enough to hit the cap. A machine that can't keep up still under-runs and goes slow-motion. Vsync hides the symptom on capable hardware; it doesn't decouple simulation from frame rate.
03Variable timestep
The obvious fix: measure how long the last frame took and scale everything by that delta time.
auto previous = Clock::now();
while (running) {
auto now = Clock::now();
double dt = std::chrono::duration<double>(now - previous).count();
previous = now;
update(dt); // position += velocity * dt
render();
}
let mut previous = Instant::now();
while running {
let now = Instant::now();
let dt = (now - previous).as_secs_f64();
previous = now;
update(dt); // position += velocity * dt
render();
}
Wall-clock speed is now consistent across frame rates, and for a lot of gameplay code this is fine. For the simulation core it brings three new failures:
- Non-determinism. Different machines produce different
dtsequences, and combined with floating-point arithmetic, different step sizes round differently and the state diverges[2]. The cause is the combination, not variabledtalone. - Large-step instability. A frame spike hands
updatea hugedt, and an explicit integrator can overshoot and blow up. This is integrator-dependent: explicit Euler is fragile, semi-implicit and higher-order schemes are more forgiving[4]. - Tunneling. A big step jumps a fast object clear past a thin wall between two discrete collision checks. That is a sampling problem, distinct from integrator instability.
04Semi-fixed: clamp the step
First partial fix: never integrate more than a maximum dt at once. If a frame took longer, take several capped steps[3].
while (frameTime > 0.0) {
double step = std::min(frameTime, dt); // never exceed dt
integrate(state, step);
frameTime -= step;
}
while frame_time > 0.0 {
let step = frame_time.min(dt); // never exceed dt
integrate(&mut state, step);
frame_time -= step;
}
This kills large-step instability and most tunneling. It is still not deterministic: the last step each frame is a partial dt, so step sizes still vary frame to frame. That trailing remainder is exactly what the accumulator pattern fixes, by deferring it instead of stepping it.
05Fixed timestep and the accumulator
The canonical loop. Accumulate real frame time, then consume it in whole dt chunks, carrying the leftover forward to the next frame[3]. Every simulation step is now exactly the same size, which makes it stable and reproducible.
using Clock = std::chrono::steady_clock; // monotonic; never wall-clock for dt
const double dt = 1.0 / 60.0; // fixed simulation step (seconds)
double accumulator = 0.0;
auto currentTime = Clock::now();
while (running) {
auto newTime = Clock::now();
double frameTime = std::chrono::duration<double>(newTime - currentTime).count();
if (frameTime > 0.25) frameTime = 0.25; // spiral-of-death clamp (Fiedler's example)
currentTime = newTime;
accumulator += frameTime;
while (accumulator >= dt) { // >=, not >: a full step is available
previousState = currentState; // keep the prior state for interpolation
integrate(currentState, dt); // every step is exactly dt
accumulator -= dt; // carry the remainder (< dt) to next frame
}
double alpha = accumulator / dt; // 0..1: how far past the last step we are
render(lerp(previousState, currentState, alpha));
}
use std::time::Instant; // monotonic; analog of steady_clock
let dt: f64 = 1.0 / 60.0; // fixed simulation step (seconds)
let mut accumulator: f64 = 0.0;
let mut current_time = Instant::now();
while running {
let new_time = Instant::now();
let mut frame_time = (new_time - current_time).as_secs_f64();
if frame_time > 0.25 { frame_time = 0.25; } // spiral-of-death clamp
current_time = new_time;
accumulator += frame_time;
while accumulator >= dt { // >=, not >: a full step is available
previous_state = current_state; // keep the prior state for interpolation
integrate(&mut current_state, dt); // every step is exactly dt
accumulator -= dt; // carry the remainder (< dt) forward
}
let alpha = accumulator / dt; // 0..1
render(lerp(&previous_state, ¤t_state, alpha as f32));
}
Three lines carry the whole design, so be precise about each:
while (accumulator >= dt)runs zero, one, or several steps this frame, depending on how much real time built up.accumulator -= dtleaves a remainder belowdtin the accumulator. It is carried, not discarded. Throwing it away makes the simulation run slow.alpha = accumulator / dtis the fraction of a step the renderer sits past the last completed state, the blend factor for interpolation in §7.
A fixed timestep means the simulation advances in constant dt increments. A fixed frame rate means the display refreshes at a constant rate. They are independent: the normal case is a fixed-timestep simulation running under a variable, display-driven frame rate. The 0.25 s clamp is Fiedler's example value, not a universal constant; pick yours from your worst tolerable hitch.
The widget runs the same bouncing object under three loops at once. Drag the render-rate slider and fire a frame spike, and watch which ones break:
Wrong answers, and why: zeroing the accumulator loses time (slow sim); a final partial step is the semi-fixed variant that breaks determinism; and fixed timestep constrains the simulation, not the display or vsync.
06Decoupling sim from render
The accumulator already decoupled the two rates. The inner loop runs the simulation zero, one, or several times per rendered frame; rendering happens once at the end, as often as the display allows. Unity says the same about its fixed step: FixedUpdate is called "zero, one, or multiple times per frame depending on the frame rate"[6], and Godot splits a fixed-rate _physics_process from a variable-rate _process[10].
In the single-threaded loop above, input is sampled once per rendered frame and fed to whatever simulation steps run that frame, not once per simulation tick. That is fine for most games and is itself a small source of input-timing variation. Lockstep and rollback netcode tie input to ticks instead, which the networking modules cover.
07Interpolation, not extrapolation
With a fixed simulation and a faster display, rendered frames fall between simulation states, so the picture stutters. The fix is to keep the previous and current states and blend them with alpha[3].
// alpha in [0,1): how far between previous and current we are.
State shown = previousState * (1.0 - alpha) + currentState * alpha;
render(shown);
// alpha in [0,1): how far between previous and current we are.
let shown = previous_state.lerp(current_state, alpha as f32);
render(shown);
Because this blends two states that were already computed, it never invents an unphysical position. The cost is latency: the image shows a moment between the last two ticks, which is up to one fixed step in the past. Unity describes its rigidbody interpolation exactly this way, blending "the previous two physics updates" so the body "appear[s] to move slightly behind where it should be"[8]; Godot calls it "estimating the position of the object in the past"[11].
Two of the most-cited game-loop articles, deWiTTERS[5] and Nystrom[2], use the word "interpolation" for code that actually extrapolates: it predicts forward from the current state and velocity (position + velocity * alpha). Extrapolation adds no latency, but when the prediction is wrong (a collision, a direction change) the next tick corrects it and the object visibly snaps. True interpolation (Fiedler, Unity, Godot) blends past and present and trades latency for never overshooting. Same word, opposite technique; know which one a given article ships.
Toggle interpolation off to see the stutter, then switch between interpolate and extrapolate and bounce the ball off a wall to see the overshoot:
08The spiral of death
One failure mode of the accumulator deserves its own name. If a single simulation step costs more than dt of real time to compute, each frame adds more to the accumulator than the inner loop can drain, so next frame needs even more steps, which take even longer[3]. The loop spirals until it locks up.
There are two guards, working from opposite ends, and engines use one or both:
- Clamp the incoming frame time (the
frameTime > 0.25line). Caps how much work can enter the accumulator per frame. - Cap the steps per frame. Bail out of the inner loop after a maximum count; "the game will slow down then, but that's better than locking up completely"[2]. Unity exposes this as Maximum Allowed Timestep[7].
Neither guard makes the simulation keep real time when the machine can't. They make it fall behind in slow motion instead of freezing. And the runaway has several possible causes (too-small dt, an expensive collision frame, a GC or allocation hitch, OS preemption), so there is no single fix, just the two backstops.
Raise the per-step cost above the frame budget and watch the accumulator. Then turn the clamp on:
09Determinism
A fixed timestep is necessary for a reproducible simulation, because every run takes the same-sized steps. It is not sufficient.
Three sources of divergence survive a fixed step. Floating-point results differ across compilers, optimization levels, architectures, and fast-math settings, so the same code can produce slightly different numbers on different toolchains. Float addition is not associative, so changing the order operations are summed changes the result. And input timing or sampling can differ run to run. Nystrom puts the practical consequence plainly: with variable steps "the same bullet will end up in different places on their machines"[2]; a fixed step removes that particular variable but not the float ones.
Float math is not "always nondeterministic." With an identical binary, identical flags, and the same architecture it can be perfectly reproducible. Divergence shows up across differing toolchains or hardware, or with fast-math. The mechanics live in the IEEE-754 module; here it is enough to know fixed step alone does not buy you cross-machine determinism.
10Timing sources
Measure frame time with a monotonic clock, never a wall clock.
- Use
std::chrono::steady_clockin C++ andstd::time::Instantin Rust. Both are monotonic and meant for measuring intervals[13][14]. A wall clock (system_clock) can jump backward on an NTP correction or a DST change and hand you a negativedt. sleep()is not precise. On Windows the default timer granularity is about 15.6 ms, soSleep(1)can sleep roughly 15 ms[15]. Precise frame pacing sleeps to near the target, then spin-waits the rest.- With vsync on, the buffer swap blocks until the display's refresh, so the swap itself paces the loop. The accumulator absorbs that cadence, and absorbs the jitter when a frame is dropped or repeated under load.
In an SDL or GLFW program you own the while loop and the accumulator drops straight in. In Rust's winit the framework owns the loop and calls you back: keep the accumulator in your handler, set ControlFlow::Poll, and do the frame work in the RedrawRequested event, not in about_to_wait[16]. Same algorithm, inverted control flow. The Platform & Window tutorial builds this out.
11Case studies
12Pitfalls
13What's next
The loop needs a window to run in and events to pump, which is the next module, Platform & Window, followed by Input. The integrator inside integrate() is the subject of the Physics tutorial, which uses the fixed step you just built for its substeps. And the determinism caveats trace back to IEEE-754 Floating Point. The full path is on the series hub.
- Jason Gregory. Game Engine Architecture, "The Game Loop and Real-Time Simulation." gameenginebook.com. The kinds of time an engine tracks and the structure of the loop. (Chapter number varies by edition; cited by title.)
- Robert Nystrom. "Game Loop," Game Programming Patterns. gameprogrammingpatterns.com. Frame-rate coupling, fixed-update/variable-render, the step-cap guard, float divergence, and an extrapolated render labelled "interpolation."
- Glenn Fiedler. "Fix Your Timestep!" gafferongames.com. The canonical accumulator algorithm, the 0.25 s clamp, the
alphablend, state interpolation, the semi-fixed loop, and the spiral of death. - Glenn Fiedler. "Integration Basics." gafferongames.com. Explicit Euler vs semi-implicit Euler vs RK4; supports scoping large-step instability to the integrator.
- Koen Witters. "deWiTTERS Game Loop." dewitters.com. Constant-logic-rate loop with frame-skip and a forward-prediction render the article calls "interpolation."
- Unity. MonoBehaviour.FixedUpdate, Scripting API. docs.unity3d.com. "Called zero, one, or multiple times per frame depending on the frame rate."
- Unity. Fixed updates / Time settings. docs.unity3d.com. Default
fixedDeltaTimeof 0.02 s and the Maximum Allowed Timestep guard. - Unity. Apply interpolation to a Rigidbody. docs.unity3d.com. Interpolation blends the previous two physics updates and renders slightly behind; extrapolation predicts ahead.
- Epic Games. Physics Sub-Stepping in Unreal Engine. dev.epicgames.com. Variable tick with Max Substep Delta Time and Max Substeps bounding the physics step.
- Godot. Idle and Physics Processing. docs.godotengine.org. Fixed-rate
_physics_process(default 60 Hz) vs variable-rate_process. - Godot. Physics interpolation introduction. docs.godotengine.org. Blending current and previous transforms, "estimating the position of the object in the past."
- Valve. Source Multiplayer Networking. developer.valvesoftware.com. "Discrete time steps called ticks," a 15 ms default step (66.67 ticks/sec) decoupled from frame rate.
- cppreference. std::chrono::steady_clock. en.cppreference.com. A monotonic clock with constant tick spacing, "most suitable for measuring intervals."
- Rust. std::time::Instant. doc.rust-lang.org. Monotonic
now()andelapsed();Duration::as_secs_f64for seconds. - Bruce Dawson. "Windows Timer Resolution: The Great Rule Change." randomascii.wordpress.com. The default ~15.6 ms timer granularity and
timeBeginPeriod. - winit. ApplicationHandler. rust-windowing.github.io. Render in response to
WindowEvent::RedrawRequested, notabout_to_wait;ControlFlow::Pollfor games.