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.
- Explain why a naive loop runs at different speeds on different machines, and why scaling by frame time brings new bugs.
- Write a fixed-timestep loop with an accumulator in C++ or Rust, and work out how many steps it runs on any given frame.
- Render smoothly between simulation ticks with interpolation, and say when extrapolation's overshoot is acceptable.
- Guard a loop against the spiral of death, and know what each guard costs.
- Pick a monotonic clock, pace frames without trusting
Sleep, and list what a fixed step doesn't make deterministic.
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:
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.
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.
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. Same inputs, different results. Different machines produce different
dtsequences. A numerical integrator's error depends on the step size, so anything accelerating (gravity, drag, springs) lands somewhere different for a different sequence of steps, and floating-point rounding adds its own drift on top: a machine taking ten times as many steps accumulates more of it[2]. - Large-step instability. A frame spike hands
updatea hugedt, and an explicit integrator (code that advances position and velocity by one finite step using only the current state) can overshoot and blow up. This is integrator-dependent: explicit Euler is fragile, while semi-implicit Euler (update velocity first, then move with the new velocity) 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. A player running at 10 m/s moves 0.17 m per step at 60 Hz, less than a 0.2 m wall; a 0.25 s hitch moves them 2.5 m in one step, straight through it. That is a sampling problem, distinct from integrator instability.
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].
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.
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, ¤t_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.
- Frame 1: accumulator 6.94 ms, no step, alpha = 6.94 / 16.67 = 0.42.
- Frame 2: 13.89 ms, no step, alpha 0.83.
- Frame 3: 20.83 ms, one step, 4.17 ms left over, alpha 0.25.
- Frame 4 hitches for 40 ms: 44.17 ms, two steps, 10.83 ms left, alpha 0.65.
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:
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:
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.
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.
// 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, ¤t_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].
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:
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:
- 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]. When the cap trips, drop the leftover time as well, or the accumulator keeps the debt and grows every frame. Unity's Maximum Allowed Timestep caps the simulated time per frame in the same spirit[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. 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:
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.
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.
- 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 when NTP steps it or a user changes the time, and hand you a negativedt. (Daylight saving doesn't move it:system_clockcounts UTC, and DST only changes how local time is displayed.) sleep()is not precise. On Windows the default timer granularity is about 15.6 ms, soSleep(1)can sleep roughly 15 ms[15]. A common approach to precise frame pacing (delivering frames at even intervals) sleeps to near the target, then spin-waits the rest, checking the clock in a tight loop.- With vsync on, the present call blocks once the swap chain is full, until a refresh frees an image, so presentation itself paces the loop. The accumulator absorbs that cadence, and absorbs the jitter, the frame-to-frame unevenness, when a frame is dropped or repeated under load.
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.
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.
12Pitfalls
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.
- 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 first described as "interpolated."
- 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.
FixedUpdate"may be called zero, one, or multiple times per frame depending on the frame rate and simulation needs." - 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, which places the drawn object "between 1 and 2 ticks into 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.