All tutorials Mighty Professional
Build a Game Engine · The Runtime Spine

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.

Time~55 min LevelBeginner to mid PrereqsYou can read C++ or Rust. The 3D Math tutorial helps, since interpolation blends two states. StackC++ & Rust
◂ Build a Game Engine Phase 3 · The Runtime Spine Next · Platform & Window ▸

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.

What you'll have by the end

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:

The frame-rate-dependent loop
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.

Trap: vsync does not fix this

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.

Variable timestep
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:

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].

Semi-fixed timestep (Fiedler)
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.

Fixed timestep with an accumulator
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, &current_state, alpha as f32));
}

Three lines carry the whole design, so be precise about each:

Fixed timestep is not fixed frame rate

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:

At 60 FPS all three look alike. Drop the render rate and the variable loop changes feel and the bare fixed loop shows quantized stutter (its drawn position snaps to tick boundaries, because the leftover accumulator is never displayed), while the interpolated loop stays smooth. The spike jolts the variable loop hardest.

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].

Where input fits, and why it is a determinism risk

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].

Render the blended state
// 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].

The naming trap: interpolation vs extrapolation

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:

Off: the ball jumps once per simulation tick. Interpolate: smooth, but the drawn ball trails the true tick position by up to one step (the ghost marks the current tick state). Extrapolate: smooth and on time mid-flight, but at a bounce the predicted position shoots through the wall and snaps back when the next tick corrects it. That snap is why interpolation is the safer default.

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:

The guard trades accuracy for liveness

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:

Below budget, the accumulator stays flat and steps-per-frame hovers near one. Push the per-step cost past the budget with the clamp off and both climb every frame, the runaway. Turn the clamp and step cap on and steps-per-frame plateaus: the simulation slows down but the loop stays alive.

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.

Scope the float claim

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.

A note on the windowing layer

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

Unity
Fixed FixedUpdate plus rigidbody interpolation. Physics runs at Time.fixedDeltaTime, default 0.02 s (50 Hz)[7]. Rigidbody Interpolate blends the previous two physics updates and so renders one update behind; Extrapolate predicts ahead instead[8]. The spiral guard is Maximum Allowed Timestep[7].
Unreal
Variable tick with physics sub-stepping. The frame tick is variable; physics stays stable by sub-dividing each frame so its step never exceeds Max Substep Delta Time, bounded by Max Substeps[9]. The sub-stepping is hidden from gameplay code.
Godot
Split processing callbacks. _physics_process runs at a fixed Physics FPS (default 60, so 16.667 ms) and _process runs at the variable frame rate[10]. Optional physics interpolation blends the current and previous transforms, explicitly rendering in the past[11].
Source
Fixed server tick. Source "simulates the game in discrete time steps called ticks," a default 15 ms step, so 66.67 ticks per second, decoupled from the client's frame rate[12]. This is the shipped descendant of the Quake fixed-tick lineage.

12Pitfalls

Game speed tracks frame rateNo delta time, or motion not scaled by it. The naive loop.
Subtle stutter at a fixed stepRendering the raw tick state without interpolating by the leftover accumulator.
Simulation runs slowDiscarding the accumulator remainder instead of carrying it.
Freeze under loadNo frame-time clamp or step cap: the spiral of death.
Objects trail or overshootInterpolation adds a frame of latency; extrapolation snaps on a bounce. Pick per system.
Negative or huge dtUsing a wall clock instead of a monotonic one; an NTP or DST jump.
Replays desync across machinesFixed step alone is not determinism; float and toolchain differences remain.

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.

  1. 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.)
  2. 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."
  3. Glenn Fiedler. "Fix Your Timestep!" gafferongames.com. The canonical accumulator algorithm, the 0.25 s clamp, the alpha blend, state interpolation, the semi-fixed loop, and the spiral of death.
  4. Glenn Fiedler. "Integration Basics." gafferongames.com. Explicit Euler vs semi-implicit Euler vs RK4; supports scoping large-step instability to the integrator.
  5. Koen Witters. "deWiTTERS Game Loop." dewitters.com. Constant-logic-rate loop with frame-skip and a forward-prediction render the article calls "interpolation."
  6. Unity. MonoBehaviour.FixedUpdate, Scripting API. docs.unity3d.com. "Called zero, one, or multiple times per frame depending on the frame rate."
  7. Unity. Fixed updates / Time settings. docs.unity3d.com. Default fixedDeltaTime of 0.02 s and the Maximum Allowed Timestep guard.
  8. Unity. Apply interpolation to a Rigidbody. docs.unity3d.com. Interpolation blends the previous two physics updates and renders slightly behind; extrapolation predicts ahead.
  9. 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.
  10. Godot. Idle and Physics Processing. docs.godotengine.org. Fixed-rate _physics_process (default 60 Hz) vs variable-rate _process.
  11. Godot. Physics interpolation introduction. docs.godotengine.org. Blending current and previous transforms, "estimating the position of the object in the past."
  12. 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.
  13. cppreference. std::chrono::steady_clock. en.cppreference.com. A monotonic clock with constant tick spacing, "most suitable for measuring intervals."
  14. Rust. std::time::Instant. doc.rust-lang.org. Monotonic now() and elapsed(); Duration::as_secs_f64 for seconds.
  15. Bruce Dawson. "Windows Timer Resolution: The Great Rule Change." randomascii.wordpress.com. The default ~15.6 ms timer granularity and timeBeginPeriod.
  16. winit. ApplicationHandler. rust-windowing.github.io. Render in response to WindowEvent::RedrawRequested, not about_to_wait; ControlFlow::Poll for games.

See also