The loop, and why your first one is wrong

You can write a fixed-timestep loop with an accumulator, render interpolated state between ticks, and cap the frame time so a slow frame cannot cascade.

The loop you write first

Every game is a loop. Read the input, advance the world, draw it, repeat. The first version you write almost always looks like this.

#include <chrono>

void pollInput();
void update(float dt);
void render();

int main() {
    using clock = std::chrono::steady_clock;
    auto previous = clock::now();
    bool running = true;

    while (running) {
        const auto now = clock::now();
        const float dt = std::chrono::duration<float>(now - previous).count();
        previous = now;

        pollInput();
        update(dt);
        render();
    }
}

Here `dt` is the real time the last frame took. Anything that moves multiplies by it. On paper that is correct. A body travelling at three metres per second covers `3 * dt` metres per frame, whatever `dt` turns out to be, so the frame rate changes and the world does not.

It holds for a while. Then it stops holding. The three ways it stops are worth knowing precisely, because everything after this lesson is a response to one of them.

How a variable timestep breaks

The first failure is that integration error tracks frame rate. Numerical integration approximates a curve with straight segments, and the error in each segment grows with the segment length. Take a jump under gravity, integrated the usual way:

void integrate(Body& b, float dt) {
    b.velocity += gravity * dt;      // gravity is about -9.81 m/s^2 in y
    b.position += b.velocity * dt;   // semi-implicit Euler
}

Run that at 60 Hz and a jump reaches one apex. Run it at 144 Hz and it reaches a slightly different one, because the discrete sum of `velocity * dt` is not the integral it is standing in for. The difference is small per step and it compounds. A gap the player clears on a fast machine becomes a gap they cannot clear on a slow one. You will find this as a bug report that reproduces on exactly one person's hardware.

The second failure is tunnelling. Collision detection usually asks whether two shapes overlap right now. A bullet at 200 metres per second moves 3.3 metres in a 60 Hz step, and 13.3 metres in a step that took a fifth of a second because the operating system went away to page something in. A bulkhead 0.2 metres thick is never overlapped at either end of that step, so the bullet is on one side, then the other, and no contact was ever detected.

The third failure is that the simulation is no longer reproducible. `dt` is a measurement of the machine. It is never the same twice, so the same inputs produce a different world on the second run. Replays drift. A physics test that passes locally fails one time in fifty in CI, and the failure carries no information because it cannot be reproduced.

SymptomCauseHow it reaches you
Jump height changes with frame rateintegration error scales with step lengtha gap that is passable on one machine only
Objects pass through thin geometryone step moves further than the collider is thicka projectile that misses a hull plate when the frame stutters
A replay diverges from the originaldt is never the same twicea flaky test with no reproduction
The tempting fix is the wrong one

Clamping `dt` to a maximum, or capping the frame rate, hides the symptom and keeps the cause. The simulation still advances by an amount nobody chose, so the results are still a function of the hardware.

A fixed step with an accumulator

Decide the simulation's step length yourself and never vary it. Real elapsed time then becomes a budget rather than a step size. You bank it in an accumulator and spend it in whole ticks.

#include <algorithm>
#include <chrono>

constexpr double kFixedStep = 1.0 / 120.0;  // seconds of world time per tick
constexpr double kMaxFrame  = 0.25;         // most real time accepted in one pass

void pollInput();
void step(double dt);
void render(double alpha);

int main() {
    using clock = std::chrono::steady_clock;
    auto previous = clock::now();
    double accumulator = 0.0;
    bool running = true;

    while (running) {
        const auto now = clock::now();
        double frame = std::chrono::duration<double>(now - previous).count();
        previous = now;

        frame = std::min(frame, kMaxFrame);
        accumulator += frame;

        pollInput();

        while (accumulator >= kFixedStep) {
            step(kFixedStep);
            accumulator -= kFixedStep;
        }

        render(accumulator / kFixedStep);
    }
}

`step` now always receives the same number. Integration error is fixed at whatever that step length buys you, and it is the same on every machine. Collision can reason about a known maximum distance per tick, which is what continuous collision detection needs later. The tick count is a clock the simulation can trust, so anything periodic can count ticks instead of summing floats.

Pick the step length deliberately. 1/60 is the common default. ABOARD runs its simulation faster than its render rate because life support, air flow through the ducts and the watch schedule all read cleanly at a fixed cadence, and a shorter step keeps contact resolution stable when a crew member walks into a moving door. Shorter steps cost CPU linearly, so measure before you halve it.

Rendering between ticks

After the inner loop drains, the accumulator holds the leftover real time that was not enough for a full tick. The world is therefore slightly behind the present. Draw it as it is and the picture judders, because the renderer is showing a state that is between zero and one tick stale, and that staleness changes every frame.

Keep two snapshots and interpolate between them.

struct Body {
    Vec3 position;
    Vec3 velocity;
};

struct World {
    std::vector<Body> bodies;
};

World previousState;   // the world as of the tick before last
World currentState;    // the world as of the last completed tick

void step(double dt) {
    previousState = currentState;
    integrate(currentState, dt);
}
void render(double alpha) {
    const float a = static_cast<float>(alpha);
    for (size_t i = 0; i < currentState.bodies.size(); ++i) {
        const Vec3 from = previousState.bodies[i].position;
        const Vec3 to   = currentState.bodies[i].position;
        drawBody(i, from + (to - from) * a);
    }
}

The renderer now draws a position the simulation never held, which is the point. Motion is smooth at any display rate, including rates that are not a multiple of the tick rate, and the simulation stays untouched. Rotations interpolate as quaternions with slerp rather than as Euler angles. Values that jump on purpose, such as a teleport or a respawn, need a flag that tells the renderer to snap instead of interpolate, or the object will glide across the level.

Copying the whole world every tick is fine while the world is small and becomes the first thing you fix when it is not. The usual answer is to store the previous value only for the components the renderer actually reads.

The spiral of death

The inner `while` loop is unbounded in principle. If a tick takes longer to simulate than the world time it represents, each pass produces more debt than it clears. The accumulator grows, the next frame runs more ticks, the frame takes longer, and the loop stops returning. The game appears to hang while working extremely hard.

`kMaxFrame` above is the cap that prevents it. Accept at most a quarter of a second of real time per pass and throw away the rest. The simulation then runs slower than wall clock under sustained load, which is visible and survivable. The alternative is a freeze.

Try this before moving on

Put a deliberate 500 ms sleep inside a single tick and watch the accumulator with the cap removed. Then restore the cap and watch what the game does instead. The difference between a hang and a slowdown is four lines.

Two refinements are worth knowing. Counting how often the cap fires gives you a cheap load signal you can log or show on a debug overlay. Separately, if the simulation is genuinely too expensive, lengthening the step is a real option: it changes integration accuracy in a way you can measure, where dropping to a variable step returns every problem in this lesson at once.

Why determinism is worth the work

A fixed step makes the simulation a pure function of its starting state and the sequence of inputs. That one property pays for several things that are otherwise hard.

A replay becomes a seed and a list of per-tick inputs, which is a few kilobytes for a long session rather than a recording of every position.

struct TickInput {
    uint32_t tick;
    uint16_t buttons;
    int8_t   moveX;
    int8_t   moveY;
};

struct Replay {
    uint64_t seed;
    std::vector<TickInput> inputs;
};

Bugs become reproducible. A crash report that carries the seed and the input log replays on your machine exactly as it happened on the player's. Tests become possible at all: you can assert that a given input sequence puts the crew member through the hatch on tick 412, and that assertion holds on every machine in CI.

Determinism does not come free with the fixed step. Floating point results vary across compilers, optimisation levels and instruction sets, so you disable fast math on the simulation path and treat any cross-platform lockstep as a separate engineering problem. Iteration order has to be stable, which means no hash maps keyed on pointers driving simulation order. Every random draw has to come from a seeded generator you own rather than from `rand`. The next lesson takes the time source itself apart, because a monotonic clock and a wall clock are different instruments and one of them is allowed to go backwards.

Back to Game development, from the loop outward