All tutorials Mighty Professional
Build a Game Engine · The runtime spine

The Game Loop & Time

Your game runs in slow motion on a weak laptop and too fast on a 144 Hz monitor. You scale movement by frame time to fix it, and now a one-frame hitch throws a crate through a wall. Both bugs live in the few lines of the game loop. This page builds that loop from the naive version up, in C++ and Rust, stopping at every failure that forced the next change, and ends with a (the simulation always advances by the same small step) driven by an (a running total of real time not yet simulated), with a render that blends between simulation states.

Time~65 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 ▸
What you'll be able to do by the end

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.

A pause menu has to stop the world while its own buttons keep animating; bullet time has to slow the world while the HUD runs at full speed. That takes more than one clock, so 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.

Worked example

Bullet time runs the simulation at 25% speed. In 2 seconds of real time, simulation time advances 0.5 s, and a grenade's 3-second fuse needs 12 real seconds to go off. Open the pause menu for 10 real seconds and simulation time advances 0, while the menu's own fade-in animation plays its full local time.

02The naive loop

The first loop everyone writes runs at the right speed on the machine it was written on. It 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. It's why some early PC games became unplayably fast on later hardware.

Worked example

update() moves the player 0.1 m. At 60 iterations a second that's 6 m/s, a brisk jog. The same build on a machine that runs the loop 144 times a second moves them at 14.4 m/s, and on a laptop that manages 30, at 3 m/s.

Trap: vsync does not fix this

Capping the loop with vsync (waiting for the display's refresh before showing each frame) bounds the upper rate, so the coupling stops being visible on hardware fast enough to hit the cap. The cap is the monitor's refresh rate, though, so the same game runs 2.4× faster on a 144 Hz display than on a 60 Hz one (144 / 60), and a machine that can't keep up still under-runs and goes slow-motion. Vsync hides the symptom on some setups; it doesn't decouple simulation from frame rate.

03Variable timestep

The obvious fix for a player who jogs on one machine and sprints on another: measure how long the last frame took and scale every movement by that delta time, the real time since the previous frame, usually called dt and measured in seconds.

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:

Worked example

Drop a ball from rest for 1 second under gravity (9.81 m/s²) with semi-implicit Euler. The exact fall is 4.905 m. A player at 144 fps takes 144 steps and the ball falls 4.939 m; at 60 fps, 4.987 m; at 30 fps, 5.069 m. Same inputs, and the 144 fps and 30 fps machines disagree by 13 cm after one second.

Now a spring: stiffness 100 N/m on a 1 kg mass, stretched 1 m. Semi-implicit Euler stays bounded for steps under 0.2 s and is smooth at 1/60 s. One 0.25 s frame is past that limit: that single step flings the mass from +1 m to −5.25 m, five times its starting stretch. If the hitches keep coming, the next steps reach 21.3, −85.3 and 341 m.

04Semi-fixed: clamp the step

The spring blew up because one step was too big, so the first partial fix is to 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 bounds the step size, which removes large-step instability and reduces tunneling (a fast enough object still crosses a thin wall within one dt). It is still not deterministic: the last step each frame is a partial dt, so step sizes still vary frame to frame.

Worked example

With dt = 1/60 s (16.67 ms), a 40 ms frame runs steps of 16.67, 16.67 and 6.67 ms. A 35 ms frame on another machine runs 16.67, 16.67 and 1.67 ms. No step is too big any more, but the two machines took different step sequences, so their results drift apart just as in the falling-ball example.

That trailing remainder is exactly what the accumulator pattern fixes, by deferring it instead of stepping it.

05Fixed timestep and the accumulator

To get the same simulation on every machine, every step has to be the same size, even though frames aren't. The canonical loop does it: 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.clone();   // 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));
}

Worked example

The simulation steps at 60 Hz (dt = 16.67 ms) and the display refreshes at 144 Hz, so each frame adds 6.94 ms.

Every step was exactly 16.67 ms. Only the number of steps per frame changed.

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:

Three tracks run the same ball from the same wall clock: orange is the variable-timestep loop, blue the fixed loop drawing its latest tick, and green the fixed loop with interpolation (its faint ghost is the latest tick). Drag the render-rate slider and press "Inject a frame spike". The simulation ticks at 16 Hz, deliberately coarse so the ticks are visible. At any render rate above that, the bare fixed loop judders: its drawn position only changes on tick boundaries, so some frames repeat a position and others jump, and its step count reads 0, 1 or 2. The interpolated loop runs the same ticks and moves as smoothly as the variable loop, because it draws the leftover accumulator as a blend. Below 16 FPS every track is choppy, since nothing can show motion between frames the display never draws. The spike adds 0.32 s to one frame. The variable loop takes all of it, plus the frame's own time, in one step; the fixed loops clamp the frame to 0.25 s and take it as four 1/16 s ticks (the blue readout shows "4 step(s) this frame"), so they jump a little less and fall slightly behind the wall clock.

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

A player with a 144 Hz monitor should see 144 frames a second, and a player on a 30 fps laptop should still get the same simulation. The accumulator already decoupled the two rates, so neither one dictates the other. 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].

Worked example

Unity's default fixed step is 0.02 s, 50 Hz. At 144 fps that's 50 / 144 = 0.35 fixed steps per rendered frame: FixedUpdate runs on about one frame in three and is skipped on the rest. At 30 fps it's 50 / 30 = 1.67 per frame, so it runs once on some frames and twice on others.

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 (one fixed step). That is fine for most games and is itself a small source of input-timing variation. Lockstep and rollback netcode (where every machine runs the same simulation from exchanged inputs, rewinding when a late input arrives in rollback's case) tie input to ticks instead, which the networking modules cover.

07Interpolation, not extrapolation

With a 60 Hz simulation on a 144 Hz display, two or three rendered frames in a row can show the same tick, then the picture jumps. 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, the leftover accumulator divided by dt[3].

Worked example

A ball moves +1 unit per tick toward a wall at x = 3.5. The previous tick put it at 2.0 and the current tick at 3.0, and this frame's alpha is 0.6.

Interpolation draws 2.0 × 0.4 + 3.0 × 0.6 = 2.6, a position the simulation actually passed through, 0.4 units behind the newest state. Extrapolation predicts forward instead: 3.0 + 1 × 0.6 = 3.6, already 0.1 inside the wall. The next tick bounces the ball back to 3.0, and the extrapolated drawing snaps back 0.6 units.

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 = lerp(&previous_state, &current_state, alpha as f32);  // previous*(1-alpha) + current*alpha
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, so it trails the newest simulated state by up to one fixed step, and real time by more. Unity describes its rigidbody interpolation this way, using "the previous two physics updates" so the body appears "to move slightly behind where it should be"[8]; Godot's docs put the rendered position "between 1 and 2 ticks into the past"[11].

The naming trap: interpolation vs extrapolation

Two of the most-cited game-loop articles blur . deWiTTERS[5] names its render factor interpolation in code that extrapolates: it predicts forward from the current state and speed (position + speed * interpolation). Nystrom[2] renders the same kind of forward prediction, describes it as "interpolated between where it was on the last frame and where we think it will be on the next frame", and only then calls it extrapolation. 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. When an article says "interpolation", check which of the two its code does.

The widget runs one ball through all three ways of drawing it:

A ball bounces between two red walls, driven by a fixed simulation that ticks only every 0.28 s so each tick is easy to see. The faint ball labeled "current tick" is the newest simulated state; the purple ball is what gets drawn. Uncheck "render between sim ticks" and the ball jumps once per tick. Interpolate: smooth, but the drawn ball trails the current tick by up to one step. Extrapolate: smooth and on time mid-flight, but at a bounce the drawn ball turns yellow as it 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

An explosion spawns two hundred rigid bodies, and one physics step now takes 20 ms to compute against a 16.7 ms step. One failure mode of the accumulator deserves its own name, the . 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.

Worked example

Take dt = 16.7 ms and a step that costs 20 ms, and assume a frame takes exactly as long as its steps. One step makes the frame 20 ms long, which feeds 20 ms into the accumulator: one step again, with 3.3 ms left over. The leftover grows by 3.3 ms a frame until a frame needs two steps, which take 40 ms, which feed in 40 ms, and so on. Steps per frame go 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5, 6, 7, 8, 10, 12, 14, 17, 21, and keep compounding.

Cap the loop at 5 steps and drop the leftover, and every frame takes 5 × 20 = 100 ms to simulate 5 × 16.7 = 83 ms. The game runs at 83% speed instead of freezing.

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. The runaway needs a per-step cost near or above dt, and several things can push a loop there or kick it off (a too-small dt, an expensive collision frame, a GC or allocation hitch, OS preemption), so there is no single fix, just the two backstops.

The widget models that loop, with a frame taking exactly as long as its steps:

The top lines read out the 16.7 ms step budget, the per-step cost, the steps this frame and the leftover accumulator; the plot tracks steps per frame (orange) and accumulator depth (violet). At the default 8 ms per step it takes one step a frame with an empty accumulator. Drag the cost to 20 ms with the clamp off and steps per frame climb every frame, past 10 within about half a second and up to the demo's 100-step safety limit within about a second; from then on the accumulator grows by about a third of a second every frame. That's the runaway. Tick "clamp + cap steps" and the readout settles at "steps this frame: 5 (capped, leftover dropped)" with the accumulator at 3 ms: the simulation slows down, but the loop stays alive.

09Determinism

A fighting game records only the players' inputs for its replays. Played back on the same build and machine, a replay matches. On a teammate's PC built with a different compiler, a hit that landed now misses. A fixed timestep is the usual foundation for a reproducible simulation, since every run takes the same-sized steps, but it is not enough on its own.

Three sources of divergence survive a fixed step. Floating-point results differ across compilers, optimization levels, architectures, and fast-math settings (compiler modes that let the optimizer reorder and fuse float math for speed), 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.

Worked example

Three forces act on a body along one axis: a = 100,000,000, b = −100,000,000 and c = 1, stored as 32-bit floats. Summed as (a + b) + c the total is 1. Summed as a + (b + c), the inner sum rounds back to −100,000,000, because floats near 108 are 8 apart, and the total is 0. If a job system adds the forces in whatever order its worker threads finish, two runs with identical inputs and identical steps can disagree.

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 IEEE-754 Floating Point; here it is enough to know fixed step alone does not buy you cross-machine determinism.

10Timing sources

A player changes the system clock while the game runs, and physics gets a negative dt; a frame limiter calls Sleep to wait out the rest of a frame and misses the next refresh. Both come from where the loop gets its time. Measure frame time with a monotonic clock, one that never goes backward, never a wall clock.

Worked example

A 60 fps frame has 16.7 ms. The game's work takes 10 ms, so the limiter calls Sleep(6). With Windows' default 15.6 ms timer granularity, that sleep can last up to about 15.6 ms, and the frame takes up to 10 + 15.6 = 25.6 ms, about 39 fps. Spin-waiting the last 6.7 ms instead hits 16.7 ms at the cost of a busy core; raising the timer resolution with timeBeginPeriod lets a short sleep cover most of that wait first[15].

Separately, if NTP steps the wall clock back 2 s during a 16.7 ms frame, a system_clock difference reports dt = 0.0167 − 2 = −1.983 s. The listing's frameTime > 0.25 clamp doesn't catch a negative value. steady_clock and Instant can't go backward, so it can't happen.

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, do the frame work in the RedrawRequested event rather than in about_to_wait, and call request_redraw() every frame so the next RedrawRequested arrives[16]. Same algorithm, inverted control flow. The Platform & Window tutorial builds this out.

11Case studies

If you ship on an existing engine, it already made these choices. Knowing which ones tells you what its physics settings do and why its docs warn about the things they warn about.

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 optional physics sub-stepping. The frame tick is variable. Sub-stepping is off by default; turned on in the project's Physics settings, it sub-divides each frame so no physics step 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].

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. A cap that keeps the leftover time only delays it.
Objects trail or overshootInterpolation adds up to a tick of latency; extrapolation snaps on a bounce. Pick per system.
Negative or huge dtUsing a wall clock instead of a monotonic one; an NTP step or manual clock change.
Replays desync across machinesFixed step alone is not determinism; float and toolchain differences remain.

13What's next

Next is Platform & Window: the loop needs a window to run in and events to pump, and that page builds them, 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 first described as "interpolated."
  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. FixedUpdate "may be called zero, one, or multiple times per frame depending on the frame rate and simulation needs."
  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, which places the drawn object "between 1 and 2 ticks into 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