Skip to content
Comfy Engine

Fixed timestep game loop in Rust: accumulator, clamp, interpolation

Fixed timestep game loop in Rust: accumulator, clamp, interpolation

A fixed timestep game loop is one of those ideas that seems like pedantry until the day your jump height depends on the player's monitor. Then it becomes the most important twenty lines in the engine. The idea is simple: the game simulates in equal slices of time, say 1/60 of a second, no matter how fast or slow frames are drawn. Rendering runs as often as it can, and blends between simulation states so motion still looks smooth.

This piece walks through why variable delta time causes trouble, how the accumulator pattern fixes it, how to stop the loop from eating itself when the machine falls behind, and how to interpolate for drawing. The code is plain Rust with no engine dependencies, so it slots into any loop, including the one built in how to make a game engine.

The trouble with variable delta time

The first loop most of us write multiplies everything by the frame's elapsed time:

fn update(state: &mut State, dt: f32) {
    state.vel_y += GRAVITY * dt;
    state.pos_y += state.vel_y * dt;
}

This looks correct. Gravity is in units per second squared, multiply by seconds, done. The problem is that this is a numerical integration, and the error of a simple integrator depends on the step size. Run the same jump at 30, 60 and 144 frames per second and you get three different peaks.

Here is a worked example. Gravity is 980 units per second squared, the jump starts with an upward velocity of 400 units per second, and we use the update above (velocity first, then position). The exact peak height is v squared over 2g, which is 160,000 divided by 1,960, about 81.6 units. With the stepped update:

Frame rate dt (s) Peak height (units)
30 fps 0.0333 about 75
60 fps 0.0167 about 78
144 fps 0.0069 about 80

Five units sounds small. On a platformer where a ledge sits at 77 units, it means the player whose laptop manages 30 frames per second cannot make a jump that someone on a 60 Hz or faster monitor clears easily. Notice also that none of the rows reach the exact answer. The error shrinks as the step shrinks, but it never goes away, and that is fine. We do not need the exact answer, we need the same answer on every machine. The same thing happens with friction, springs, and anything else that compounds over time.

There are two more problems. Collision checks with a large dt let fast objects pass through thin walls, because one step jumps them from one side to the other. And nothing is reproducible: a replay recorded at one frame rate drifts when played at another, because each frame did slightly different maths.

The accumulator pattern

The fix is to decouple the simulation rate from the frame rate. Keep a bank of unsimulated time. Each frame, add the real elapsed time to the bank. Then, while the bank holds at least one full step, simulate one step and take it out of the bank.

const STEP: f64 = 1.0 / 60.0;

pub struct Clock {
    last: f64,
    accumulator: f64,
}

impl Clock {
    pub fn new(now: f64) -> Self { Clock { last: now, accumulator: 0.0 } }

    /// Returns how many fixed steps to run this frame.
    pub fn advance(&mut self, now: f64) -> u32 {
        self.accumulator += now - self.last;
        self.last = now;
        let mut steps = 0;
        while self.accumulator >= STEP {
            self.accumulator -= STEP;
            steps += 1;
        }
        steps
    }
}

The game's simulation now always sees STEP and nothing else. At 30 frames per second, the loop runs two steps per frame on average. At 144 it runs zero steps on most frames and one step on others. Either way, after one second of real time, sixty steps have happened, each identical to the others.

I use f64 for the clock and the accumulator. Time in seconds since startup grows, and an f32 loses the precision needed for millisecond differences after a few hours. Simulation values can stay f32.

The spiral of death

There is a nasty failure case. Suppose one simulation step takes 20 milliseconds of real CPU time, longer than the 16.7 milliseconds it represents. Each frame, the accumulator gains more time than the steps can consume. Next frame there are more steps to run, which take longer, which adds more time. The game slows to a crawl and never recovers. This is the spiral of death.

It also happens in milder forms. Dragging a window on some systems pauses the loop for a second. Hitting a breakpoint pauses it for a minute. Returning from either, the accumulator holds dozens or thousands of steps, and the game freezes while it fast-forwards through them.

The fix is to cap how much time the accumulator may gain in one frame:

const MAX_FRAME: f64 = 0.25;

pub fn advance(&mut self, now: f64) -> u32 {
    let frame = (now - self.last).min(MAX_FRAME);
    self.last = now;
    self.accumulator += frame;
    let mut steps = 0;
    while self.accumulator >= STEP && steps < 8 {
        self.accumulator -= STEP;
        steps += 1;
    }
    steps
}

Two limits work together here. MAX_FRAME drops any real time beyond a quarter of a second, so the game simply runs slower than real time during a stall rather than trying to catch up. The step cap of eight stops a single frame from running too many updates. If the cap is hit, the remaining accumulator carries over, and the clamp keeps it from growing without bound.

Pick the numbers from your step size. At 60 Hz, eight steps is about 133 milliseconds of simulated time per frame, which is plenty to absorb a hiccup without turning a stall into a long freeze.

Interpolation for rendering

With a fixed step at 60 Hz and a monitor at 144 Hz, most frames draw without a new simulation step. If you draw the current state as is, objects move in visible jumps: still, still, jump, still, jump. The fix is to draw a blend between the previous and current simulation states.

After the steps run, the accumulator holds a leftover amount less than one step. Divide by the step to get alpha, a number between 0 and 1 saying how far we are between the last state and the next one.

#[derive(Clone, Copy)]
pub struct Body { pub pos: [f32; 2] }

pub fn lerp(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] {
    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}

pub fn frame(clock: &mut Clock, now: f64, prev: &mut Vec<Body>, curr: &mut Vec<Body>) {
    for _ in 0..clock.advance(now) {
        prev.clone_from(curr);
        simulate(curr, STEP as f32);
    }
    let alpha = (clock.accumulator / STEP) as f32;
    for (p, c) in prev.iter().zip(curr.iter()) {
        draw(lerp(p.pos, c.pos, alpha));
    }
}

Before each step we copy the current state into prev. Rendering then shows a point between them. This means what you see is always up to one step behind the newest simulation. At 60 Hz that is at most about 16.7 milliseconds, which players do not notice in most games, and in exchange motion is smooth at any refresh rate.

A few practical notes on interpolation:

  • Only interpolate what moves smoothly. Position, rotation and camera, yes. Animation frame indices, health bars and score, no. Draw those from the current state.
  • Teleports need a flag. When an object respawns, set its previous position equal to its new one, or it will streak across the screen for one frame.
  • Spawned objects have no previous state. Copy current into previous on the spawn step.
  • Cloning every body every step is fine for hundreds of objects. For tens of thousands, store only the previous positions in a parallel Vec<[f32; 2]>.

Where input fits

Input is sampled once per frame, but steps may run zero, one or several times per frame. Two rules keep this sane.

Held inputs, such as moving right, are read inside each step from the current snapshot. That works naturally. Pressed inputs, such as jump, are trickier: if a frame runs two steps, both would see the press and the player could double-jump. If a frame runs zero steps, the press is seen by none and gets lost.

The usual fix is to latch presses. When a press arrives, set a flag. The first fixed step that runs consumes it and clears it. Then exactly one step reacts to each press, regardless of the frame rate.

Choosing the step size

60 Hz is the common default because it matches many monitors and gives enough resolution for platformer physics. Some choices to consider:

Step Good for Cost
30 Hz Turn-based or slow strategy games, mobile battery Coarse collisions, more visible interpolation lag
60 Hz Most action games Standard
120 Hz Fast physics, fighting games, precise platformers Twice the simulation CPU

Whatever you pick, keep it a constant. Changing the step at runtime brings back the variable delta problems you just removed.

How this fits the rest of the engine

The fixed step and the variable frame are two separate calls into game code: fixed_update for simulation and update (or draw) for everything that runs once per frame, such as user interfaces and particle effects that do not affect gameplay. The layering in game engine architecture shows where each call sits in the frame. Frameworks differ on how much of this they do for you, and macroquad vs Bevy compares two quite different approaches.

Questions people ask

Is the accumulator deterministic?

The number of steps over a run depends on real timing, but each step does the same maths. If you record which inputs applied to which step number, a replay produces the same result on the same build and platform. Bit-identical results across different CPUs need more care with floating point.

What if my game is slow even at one step per frame?

Then the clamp and step cap keep the game responsive but it runs slower than real time. Profile the simulation, because no loop trick fixes a step that costs more than its budget.

Do I need interpolation for a pixel art game?

Usually yes for the camera and moving sprites, but snap the interpolated result to whole pixels when drawing, or sprites will shimmer.

As an exercise, run the jump from the first table with your own loop at a variable dt, then with the accumulator, and print the peak height each time. When both 30 and 144 frames per second give the same number, the loop is doing its job.

Filed in Engines & Architecture

Written by Tendai Mukwena

Tendai writes about architecture: game loops, entity component systems, collision, pathfinding and procedural generation, usually with a profiler open. He works on simulation tools by day and builds roguelikes at night in Harare.

Keep reading