Table of contents
Open Table of contents
The Game Loop
The first engine loop everyone writes looks like this:
while (game_running) {
character.position.x += 5.0;
render(character);
}
It works on your machine. Then you send the build to a friend with a 144Hz monitor and they call you asking why the character moves five times faster than on your laptop. The frame rate is dictating the simulation speed. At 30 FPS you get 150 pixels per second. At 144 FPS you get 720 pixels per second. Hardware determines gameplay.
Demo: Hardware Dependent Movement
Drag the slider to change the "Hardware Speed" (Simulated FPS). Watch how the box speed changes.
Attempt 2: Delta Time
The obvious fix is to measure the time between frames and scale movement by that duration. This is delta time, usually abbreviated as dt.
double last_time = get_time();
while (game_running) {
double current_time = get_time();
double dt = current_time - last_time;
last_time = current_time;
character.position.x += 300.0 * dt;
render(character);
}
Now both machines move the character 300 pixels per second regardless of frame rate. The math checks out: at 60 FPS, dt is roughly 0.0166, so you move 5 pixels per frame. At 30 FPS, dt is 0.0333, so you move 10 pixels per frame. Same distance over the same wall clock time.
Demo: The Delta Time Fix
Off: Move fixed pixels per frame.
On: Move fixed pixels per second.
You will start getting bug reports. Players falling through floors during lag spikes. Physics exploding when background processes hitch the game. The math is correct in theory, but it fails in practice because physics engines use numerical integration.
The Trap of Variable Delta Time
Physics engines integrate forces over discrete time steps. The formula position += velocity * dt assumes velocity is constant over the entire interval. It is not. Gravity, friction, and collision responses constantly change velocity. When dt is small (high frame rate), the error is negligible. When dt grows large (low frame rate or stutter), the error becomes massive. The simulation overshoots, undershoots, or misses collisions entirely.
Tunneling is the most visible symptom. Discrete collision detection only checks object positions at the end of each frame. It does not verify what happened between point A and point B. At 60 FPS, a fast-moving bullet might move 10 units per frame and land inside a wall, triggering a collision response. At 5 FPS, that same bullet moves 120 units in one jump, starting in front of the wall and ending up behind it. No collision is detected because the intermediate positions were never checked.
Continuous collision detection solves this by sweeping shapes along their trajectory and testing the entire volume, but it is computationally expensive. Most games stick with discrete checks and try to keep dt small enough that tunneling is rare.
Demo: Quantum Tunneling
Lower the simulated FPS and/or increase the speed to see the ball "tunnel" through the wall.
A subtler problem is determinism. Because dt varies with frame rate, and because floating point rounding depends on the values being operated on, the simulation produces slightly different results on different hardware or even on the same hardware under different loads. After a thousand frames, a character’s position might differ by a fraction of a pixel. After ten thousand frames, that error compounds into visible desynchronization, replays cannot be trusted, and networked multiplayer drifts out of sync.
The Solution: Fixed Timestep
You need two separate clocks. The render loop should run as fast as the hardware allows, producing smooth visuals. The physics loop should run at a strict, fixed interval, ensuring stable integration.
The accumulator pattern bridges these two timelines. Every frame, you deposit the elapsed real time into a bucket (the accumulator). Then you withdraw fixed-size chunks (your physics timestep) and run the simulation until the bucket no longer has enough for another step.
const double dt = 1.0 / 60.0;
double current_time = get_time();
double accumulator = 0.0;
while (game_running) {
double new_time = get_time();
double frame_time = new_time - current_time;
current_time = new_time;
accumulator += frame_time;
while (accumulator >= dt) {
integrate_physics(current_state, dt);
accumulator -= dt;
}
render(current_state);
}
Now integrate_physics always receives exactly 0.0166 seconds. A fast PC running at 240 FPS might render fifteen times between physics updates. A slow PC at 30 FPS might run two physics steps per rendered frame. The simulation itself is identical on both machines.
Note on Input: Input is typically read once per frame, but you might consume multiple physics steps before rendering. Applying the current frame’s input to all intermediate physics steps is usually acceptable. For competitive multiplayer where every microsecond matters, you would instead timestamp inputs and apply them to the specific simulation tick when they occurred, potentially rewinding and replaying a few frames to correct for network latency.
The Spiral of Death
One edge case remains. If your physics calculation takes longer than dt to execute, you fall behind. Imagine your physics step is meant to take 16ms (dt = 0.016), but due to complexity, it actually takes 20ms.
At frame 1, you accumulate 16ms. You enter the physics loop, but the calculation takes 20ms. By the time you finish, 20ms of real time has passed. At frame 2, you add those 20ms to whatever was left in the accumulator. Now you have roughly 20ms accumulated. You need to run two physics steps (32ms total) to catch up. But those two steps take 40ms of real time.
Now you are even further behind. The accumulator grows, you run more steps to catch up, which takes longer, putting you further behind. The game freezes and stops responding. This is the spiral of death.
Clamp the accumulator to prevent this:
accumulator += frame_time;
if (accumulator > 0.25) {
accumulator = 0.25;
}
If the game falls more than a quarter second behind, it accepts the slowdown rather than crashing. The player sees slow motion for a moment, which is preferable to an unresponsive window.
Why Doubles Fail for Time
Using double for time seems fine until you leave a game running for hours. Floating point precision degrades as numbers grow larger. At time = 0.0, you have nanosecond precision. At time = 10000.0 (about three hours), precision drops to roughly one millisecond. Animations jitter. Physics stutters. Worse, different CPUs or compiler optimizations produce slightly different bit patterns when adding large and small numbers together.
Use 64-bit integers measured in ticks or nanoseconds. A 64-bit integer at nanosecond precision will not overflow for 292 years, and addition is always exact.
constexpr int64_t dt = 16666667; // 60Hz in nanoseconds
int64_t current_time = get_time();
int64_t accumulator = 0;
while (game_running) {
int64_t new_time = get_time();
int64_t frame_time = new_time - current_time;
if (frame_time > 250000000) {
frame_time = 250000000;
}
current_time = new_time;
accumulator += frame_time;
while (accumulator >= dt) {
previous_state = current_state;
integrate_physics(current_state, dt / 1000000000.0);
accumulator -= dt;
}
// ... Render ...
}
Convert to seconds (or keep everything in fixed point) only when passing to the physics math.
Interpolation
Decoupling physics from rendering creates a visual problem. If physics updates at 60Hz but the monitor refreshes at 144Hz, you will sometimes render the same physics frame twice. If the monitor is at 30Hz, you skip physics frames entirely. Worse, if you render halfway between two physics ticks, the object appears to teleport from its old position to its new one.
The fix is to interpolate between the previous physics state and the current one using the leftover time in the accumulator.
const double alpha = (double)accumulator / (double)dt;
State interpolated_state = interpolate(previous_state, current_state, alpha);
render(interpolated_state);
If the accumulator contains half a timestep’s worth of time, alpha is 0.5 and you render the object exactly halfway between its previous and current positions. This adds exactly one frame of latency (16ms at 60Hz) because you are always rendering a blend of the past and the present, but it eliminates visual stutter completely.
Try the demo below. Drag the physics rate down to something low like 10Hz and watch the green box (interpolated). It always trails slightly behind the red box (raw physics position) because it is rendering a blend of the previous state and current state. That lag is the one-frame cost of interpolation.
Demo: Smoothing the Jitter
Floating Point Determinism
Fixed timesteps get you most of the way to determinism, but floating point math is inherently non-associative. (a + b) + c does not necessarily equal a + (b + c) due to rounding errors at different magnitudes. Compiler optimizations can reorder operations. Different architectures (x86 vs ARM) handle denormals or rounding modes differently. Fused Multiply-Add instructions compute a * b + c with a single rounding instead of two, producing different results than separate operations.
To achieve bit-identical simulation across platforms:
- Disable fast-math optimizations (
-ffloat-storeon GCC,/fp:stricton MSVC) - Avoid transcendental functions from the standard library (implement your own
sin/cosor use lookup tables) - Ensure consistent FMA usage across all targets
- Use strict IEEE 754 compliance
Note: For a deep dive into the modern state of floating point determinism, Erin Catto’s article Box2D Determinism is an excellent resource.
Fixed Point
When you absolutely cannot tolerate desync, use integers. Fixed point math stores fractional values in the lower bits of a 64-bit integer. A 32:32 split gives you a range of roughly ±2 billion with sub-micrometer precision.
struct fixed64 {
int64_t value;
};
fixed64 operator*(fixed64 a, fixed64 b) {
fixed64 result;
#ifdef _MSC_VER
int64_t high;
int64_t low = _mul128(a.value, b.value, &high);
result.value = (int64_t)((unsigned __int64)low >> 32 | (unsigned __int64)high << 32);
#else
__int128 temp = (__int128)a.value * (__int128)b.value;
result.value = (int64_t)(temp >> 32);
#endif
return result;
}
Fixed point provides uniform precision across the entire range (unlike floats, which get fuzzier as numbers grow) and identical behavior on every CPU architecture.
Demo: Precision Loss at Distance
Simulating an object moving slowly at a huge X coordinate.
Float (Red): Uses 32-bit float emulation.
Fixed (Green): Uses 64-bit Integers (32:32).
Conclusion
The fixed timestep accumulator pattern costs almost nothing to implement and saves you from an entire category of platform-specific bugs. It decouples your simulation from hardware performance. Use integers for timekeeping. If you need determinism for multiplayer or replays, audit your floating point usage or move to fixed point.
Resources
- Fix Your Timestep! by Glenn Fiedler
- Box2D Determinism by Erin Catto
- Game Loop by Robert Nystrom