Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 22 – C++ for game development

A C++ game loop: fixed timestep and determinism

Write a C++ game loop that runs physics in fixed 1/60 s steps with std::chrono, draws between steps, and replays bit for bit on GCC and Clang.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write a game loop with a fixed simulation timestep and an accumulator
  • Separate the simulation from rendering, and interpolate between two states
  • Measure frame time with std::chrono::steady_clock and limit long frames
  • Design a deterministic simulation that replays and tests without a window

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a game loop with a fixed simulation timestep and an accumulator
  • Separate the simulation from rendering, and interpolate between two states
  • Measure frame time with std::chrono::steady_clock and limit long frames
  • Design a deterministic simulation that replays and tests without a window

Before you start

On this page

A game that moves its physics forward by each frame’s measured length plays a different game on every display: in this lesson’s run, the same 5 m/s jump peaks at 1.1920 m at 30 frames a second and at 1.2569 m at 144. A loop with a A simulation step of constant length, such as 1/60 s, used for every update whatever a frame lasts, so that every frame rate computes the same states. removes the difference: it adds each frame’s real length to the time it owes the simulation, runs as many whole steps of exactly 1/60 s as that time covers, and then draws, so a faster display shows more frames of the same game.

A fixed step is also the first condition for a A simulation that turns the same inputs into the same state, bit for bit, on every run and every machine, so that a session can be replayed from its recorded inputs., one that can be replayed, tested without a window and kept in step across a network. You will build the loop with std::chrono, follow its time accounting frame by frame, keep slow updates from freezing the game, and make two compilers agree on every bit.

A variable timestep plays a different game at every frame rate

The simplest loop measures each frame and moves the simulation forward by that frame’s duration. jumper.h holds the simulation of this lesson’s examples: a character who jumps straight up at 5 m/s. Each update changes the speed by gravity, then the height by the new speed, the two lines of An integration method that, once per step, first changes the speed by the acceleration and then the position by the new speed. Its result depends on the length of the step. integration:

jumper.h C header · jumper.h
// The simulation of this lesson's examples: a character that jumps straight up at 5 m/s.
#pragma once
#include <algorithm>

struct Jumper {
    double y = 0.0;           // height above the ground, in metres
    double vy = 0.0;          // vertical speed, in metres per second
    double previous_y = 0.0;  // the height one update earlier, for drawing between the two
    double peak = 0.0;        // the highest point reached so far

    void jump() {
        if (y == 0.0) vy = 5.0;
    }

    // Moves the simulation dt seconds forward: gravity changes the speed, the speed the height.
    void advance(double dt) {
        previous_y = y;
        vy -= 9.81 * dt;
        y += vy * dt;
        if (y <= 0.0) {  // landed
            y = 0.0;
            vy = 0.0;
        }
        peak = std::max(peak, y);
    }
};
variable_step.cpp C++ · variable_step.cpp
// A variable timestep: every frame moves the simulation forward by however long that frame took.
// The same jump, at four frame rates.
#include <cstdint>
#include <print>
#include <random>
#include <vector>
#include "jumper.h"

// n frames at a steady rate, in seconds.
std::vector<double> steady(double fps, int n) { return std::vector<double>(n, 1.0 / fps); }

// n frames between 4 and 40 ms long, from a seeded generator, so every run sees the same frames.
std::vector<double> irregular(int n) {
    std::mt19937 gen{7};
    std::vector<double> frames;
    for (int i = 0; i < n; ++i) frames.push_back((4'000 + gen() % 36'001) / 1e6);
    return frames;
}

double peak_of_jump(const std::vector<double>& frames) {
    Jumper j;
    j.jump();
    for (double dt : frames) j.advance(dt);  // the frame's own length is the timestep
    return j.peak;
}

int main() {
    std::println("variable timestep, the same jump:");
    std::println("  30 fps      peak {:.4f} m", peak_of_jump(steady(30, 60)));
    std::println("  60 fps      peak {:.4f} m", peak_of_jump(steady(60, 120)));
    std::println("  144 fps     peak {:.4f} m", peak_of_jump(steady(144, 288)));
    std::println("  irregular   peak {:.4f} m", peak_of_jump(irregular(100)));
    std::println("  exact       peak {:.4f} m (v * v / 2g)", 5.0 * 5.0 / (2 * 9.81));
}

The builds of the jump add -ffp-contract=off, which makes both compilers round floating-point arithmetic the same way; the section on rounding below shows why that matters.

The same jump at four frame rates, both compilers Python · variable_session.py
from session import run

# variable_step.cpp built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off variable_step.cpp -o variable_step && ./variable_step
g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off variable_step.cpp -o variable_step-gcc && ./variable_step-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off variable_step.cpp -o variable_step && ./variable_step
variable timestep, the same jump:
  30 fps      peak 1.1920 m
  60 fps      peak 1.2329 m
  144 fps     peak 1.2569 m
  irregular   peak 1.2115 m
  exact       peak 1.2742 m (v * v / 2g)
$ g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off variable_step.cpp -o variable_step-gcc && ./variable_step-gcc
variable timestep, the same jump:
  30 fps      peak 1.1920 m
  60 fps      peak 1.2329 m
  144 fps     peak 1.2569 m
  irregular   peak 1.2115 m
  exact       peak 1.2742 m (v * v / 2g)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 variable_session.py

At 30 frames a second the jump peaks 6.5 cm lower than at 144, 1.1920 m against 1.2569 m, enough to decide whether a character reaches a ledge. Irregular frames, as on a busy computer, give yet another height, 1.2115 m. None of them reaches the exact 1.2742 m, because each update treats the speed as constant for a whole frame. Added up to the top of the jump, the loss comes to about half a frame’s travel at the launch speed:

peak≈v22g−vΔt2\text{peak} \approx \frac{v^2}{2g} - \frac{v\,\Delta t}{2}

In words: the exact height minus the launch speed times half the step. At 30 fps that is 5 m/s × (1/30) s ÷ 2 = 8.3 cm, and the run lost 8.2 cm. The three steady rates side by side:

Frames a second Peak, recorded Below 1.2742 m v × Δt ÷ 2
30 1.1920 m 8.2 cm 8.3 cm
60 1.2329 m 4.1 cm 4.2 cm
144 1.2569 m 1.7 cm 1.7 cm

Halve the frame and the loss halves. So the game plays differently on a faster machine, and a bug report cannot be reproduced, because the frame times are never the same twice.

Projectile Motion Calculator Enter 5 m/s at 90° to get the exact maximum height, 1.274 m, that the simulated jumps approach.

Checkpoint

Check: Why a variable step changes the jump

  1. Question 1 of 2 The loop uses each frame's length as its step. At 20 frames a second, about how far below the exact 1.2742 m does the 5 m/s jump peak? Use the lesson's estimate, v × Δt ÷ 2.

    Type a number.

    A hint

    At 20 frames a second, Δt is 1/20 s.

    Show the answer: Question 1 of 2

    Answer: 12.5 cm (anything from 12 to 13 counts)

    5 m/s × 0.05 s ÷ 2 = 0.125 m, 12.5 cm: three times the 4.1 cm measured at 60 fps, because the step is three times as long. The shortfall grows in proportion to the step, so slow devices get the lowest jumps.

  2. Question 2 of 2 A player reports that a jump failed on their computer. The loop uses each frame's length as its step. Why can the team not replay the session to see the same failure?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The frame lengths of that session were part of the physics, and no other run sees the same frame lengths

    With a variable timestep the simulation reads the frame's length, a value that depends on everything else the computer was doing at that moment. No later run sees the same lengths, so none computes the same jump.

A fixed step keeps game time apart from real time

The fix keeps real time and game time apart. Real time is measured once per frame and added to the time the loop owes the simulation. The simulation then advances in whole steps of exactly 1/60 s until less than one step is owed, and the frame is drawn. loop.h is that loop, written once for any clock:

loop.h C header · loop.h
// A fixed-timestep game loop that runs on any clock: std::chrono::steady_clock in the game,
// a clock moved by hand in tests (fixed_step.cpp).
#pragma once
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <ratio>
#include <type_traits>

namespace game {

// One simulation step lasts exactly 1/60 s: a chrono duration whose tick is 1/60 of a second.
using Step = std::chrono::duration<std::int64_t, std::ratio<1, 60>>;
// The same step in seconds, for the physics code.
inline constexpr double step_seconds = std::chrono::duration<double>(Step{1}).count();

// Anything with an update() that advances it by one step can be driven by the loop.
template <class T>
concept Simulation = requires(T& sim) { sim.update(); };

template <class Clock>
class FixedStepLoop {
public:
    // A frame longer than max_frame (a breakpoint, a dragged window) counts as max_frame.
    explicit FixedStepLoop(typename Clock::duration max_frame = std::chrono::milliseconds{250})
        : max_frame_{max_frame} {}

    // Call once per rendered frame. Runs as many fixed steps as the time since the last call
    // allows and returns how far the simulation already is into the next step, from 0 to 1.
    template <Simulation Sim>
    double frame(Sim& sim) {
        const auto now = Clock::now();
        const auto elapsed = std::min<typename Clock::duration>(now - previous_, max_frame_);
        previous_ = now;
        accumulator_ += elapsed;
        while (accumulator_ >= Step{1}) {
            sim.update();
            accumulator_ -= Step{1};
            ++steps_;
        }
        return std::chrono::duration<double>(accumulator_) / Step{1};
    }

    std::int64_t steps() const { return steps_; }

private:
    // The common type of the clock's tick and 1/60 s holds both exactly, so the leftover time
    // never collects rounding errors, however long the game runs.
    using Accumulator = std::common_type_t<typename Clock::duration, Step>;

    typename Clock::time_point previous_ = Clock::now();
    typename Clock::duration max_frame_;
    Accumulator accumulator_{0};
    std::int64_t steps_ = 0;
};

}  // namespace game

One call of frame(), drawn as a flow from measuring the frame to drawing it:

One frame of the fixed-timestep loop: measure, clamp, add to the time owed, run whole steps, then draw between two states.measure the frame:now - previous (steady_clock)longer than 250 ms?count it as 250 msowed += frame timewhile owed >= one step (1/60 s):update() advances exactly 1/60 s,owed -= one stepalpha = owed / step(0 to 1)draw between the lasttwo states, alpha ofthe way to the newestless than a step leftnext frame

One frame of the fixed-timestep loop

Text description of the diagram

The flow chart reads from top to bottom and then loops back to the top.

- Measure the frame: the time from the previous frame to now, read from std::chrono::steady_clock. - If that time is longer than 250 ms, count it as 250 ms. - Add it to the time owed to the simulation. - While at least one step (1/60 s) is owed, update() advances the simulation by exactly 1/60 s and one step is taken off the time owed. - When less than a step is left, alpha is the time still owed divided by the length of a step, a number from 0 to 1. - The frame is drawn between the last two states of the simulation, alpha of the way to the newest one. - An arrow labelled "next frame" leads back to measuring.

game::Step is a std::chrono::duration whose tick is 1/60 s, so one step is exactly Step{1}. The clocks here count nanoseconds, and 1/60 s is not a whole number of them: 16,666,666.67. The time owed, the loop’s The real time a fixed-step loop has not simulated yet: every frame adds its measured length, and every update takes one whole step off it., is therefore kept in the common type of the two durations, whose tick is the greatest common divisor of their periods, 1/3,000,000,000 s. A frame and a step are both whole numbers of that tick, so adding and subtracting them never rounds, where a double of seconds would add a small error on every frame.

The clock is a template parameter, and the simulation is anything with an update() function, which the Simulation concept: A named compile-time test of template arguments, such as std::integral<T>, defined as template <typename T> concept Name = condition;. Used in a template's declaration, it makes the template accept only the types that satisfy it, and a type that fails is reported at the call. More in “C++20 concepts and requires-clauses” checks. fixed_step.cpp passes a ManualClock: a struct with the members every C++ clock has (rep, period, duration, time_point, is_steady and now()) whose time moves only when the program moves it, so a test can play any pattern of frames without waiting for real time to pass:

fixed_step.cpp C++ · fixed_step.cpp
// The same jump, driven by FixedStepLoop: the simulation always advances by exactly 1/60 s,
// whatever the frame rate. A clock moved by hand stands in for std::chrono::steady_clock.
#include <bit>
#include <chrono>
#include <cstdint>
#include <initializer_list>
#include <print>
#include <random>
#include <vector>
#include "jumper.h"
#include "loop.h"

using std::chrono::nanoseconds;

// A clock that only moves when the program moves it: same interface as steady_clock.
struct ManualClock {
    using rep = std::int64_t;
    using period = std::nano;
    using duration = nanoseconds;
    using time_point = std::chrono::time_point<ManualClock>;
    static constexpr bool is_steady = true;
    static inline time_point current{};
    static time_point now() { return current; }
};

// A fingerprint of every bit of the state: equal fingerprints, bit-for-bit equal states.
std::uint64_t fingerprint(const Jumper& j) {
    std::uint64_t h = 14695981039346656037u;  // FNV-1a over the bytes of the four doubles
    for (double v : {j.y, j.vy, j.previous_y, j.peak}) {
        for (int shift = 0; shift < 64; shift += 8) {
            h ^= (std::bit_cast<std::uint64_t>(v) >> shift) & 0xff;
            h *= 1099511628211u;
        }
    }
    return h;
}

struct JumpSim {
    Jumper body;
    int steps = 0;
    std::uint64_t at_step_600 = 0;
    void update() {
        body.advance(game::step_seconds);  // always 1/60 s
        if (++steps == 600) at_step_600 = fingerprint(body);
    }
};

// Frame lengths in nanoseconds: frame i of a steady rate ends at i / fps seconds, rounded down.
std::vector<nanoseconds> steady(std::int64_t fps) {
    std::vector<nanoseconds> frames;
    for (std::int64_t i = 1; i <= fps; ++i) frames.push_back(nanoseconds{i * 1'000'000'000 / fps - (i - 1) * 1'000'000'000 / fps});
    return frames;
}

std::vector<nanoseconds> irregular() {
    std::mt19937 gen{7};
    std::vector<nanoseconds> frames;
    for (int i = 0; i < 100; ++i) frames.push_back(nanoseconds{4'000'000 + gen() % 36'000'001});
    return frames;
}

// Ten seconds of game time (600 steps), with frames of these lengths, repeated.
void run(const char* name, const std::vector<nanoseconds>& frames) {
    game::FixedStepLoop<ManualClock> loop;
    JumpSim sim;
    sim.body.jump();
    int rendered = 0;
    for (std::size_t i = 0; loop.steps() < 600; ++i) {
        ManualClock::current += frames[i % frames.size()];  // the frame took this long
        loop.frame(sim);
        ++rendered;
    }
    std::println("  {:<10} {:>5} frames   peak {:.4f} m   state after 600 steps {:016x}", name, rendered, sim.body.peak,
                 sim.at_step_600);
}

// The first frames at 144 fps: one step every 2.4 frames. "newest" is the height of the latest
// state, which only changes when a step runs; "drawn" is the height drawn between the last two.
void show_interpolation() {
    game::FixedStepLoop<ManualClock> loop;
    JumpSim sim;
    sim.body.jump();
    const auto frames = steady(144);
    std::println("\nthe first 7 frames at 144 fps:");
    std::println("  frame  steps so far  alpha  newest height  drawn height");
    for (int i = 0; i < 7; ++i) {
        ManualClock::current += frames[static_cast<std::size_t>(i)];
        const double alpha = loop.frame(sim);
        const double drawn = sim.body.previous_y + (sim.body.y - sim.body.previous_y) * alpha;
        std::println("  {:>5}  {:>12}  {:>5.2f}  {:>11.4f} m  {:>10.4f} m", i + 1, loop.steps(), alpha, sim.body.y, drawn);
    }
}

int main() {
    std::println("fixed timestep, the same jump:");
    run("30 fps", steady(30));
    run("60 fps", steady(60));
    run("144 fps", steady(144));
    run("irregular", irregular());
    show_interpolation();
}
The jump with a fixed step, four frame patterns, both compilers Python · fixed_session.py
from session import run

# fixed_step.cpp (with loop.h and jumper.h) built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off fixed_step.cpp -o fixed_step && ./fixed_step
g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off fixed_step.cpp -o fixed_step-gcc && ./fixed_step-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off fixed_step.cpp -o fixed_step && ./fixed_step
fixed timestep, the same jump:
  30 fps       300 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf
  60 fps       600 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf
  144 fps     1440 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf
  irregular    435 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf

the first 7 frames at 144 fps:
  frame  steps so far  alpha  newest height  drawn height
      1             0   0.42       0.0000 m      0.0000 m
      2             0   0.83       0.0000 m      0.0000 m
      3             1   0.25       0.0806 m      0.0202 m
      4             1   0.67       0.0806 m      0.0537 m
      5             2   0.08       0.1585 m      0.0871 m
      6             2   0.50       0.1585 m      0.1195 m
      7             2   0.92       0.1585 m      0.1520 m
$ g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off fixed_step.cpp -o fixed_step-gcc && ./fixed_step-gcc
fixed timestep, the same jump:
  30 fps       300 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf
  60 fps       600 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf
  144 fps     1440 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf
  irregular    435 frames   peak 1.2329 m   state after 600 steps bfc40b1e61f2f2bf

the first 7 frames at 144 fps:
  frame  steps so far  alpha  newest height  drawn height
      1             0   0.42       0.0000 m      0.0000 m
      2             0   0.83       0.0000 m      0.0000 m
      3             1   0.25       0.0806 m      0.0202 m
      4             1   0.67       0.0806 m      0.0537 m
      5             2   0.08       0.1585 m      0.0871 m
      6             2   0.50       0.1585 m      0.1195 m
      7             2   0.92       0.1585 m      0.1520 m

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fixed_session.py

The jump now peaks at 1.2329 m at every frame rate, the height that 60 updates a second give, and the fingerprint of every bit of the state after 600 steps is the same for all four frame patterns and both compilers. The frame rate only decides how many frames show those 600 steps: 300 at 30 fps, 1,440 at 144 fps. Follow the first seven of those 1,440 frames, the table at the end of the run: the time owed grows by 6.94 ms a frame and drops by a whole step, 16.67 ms, each time an update runs.

Interactive · step by step The time owed during seven frames at 144 Hz Step through the first seven frames of the 144 fps run. Each frame adds its real length to the time owed; each update takes one whole step of 16.67 ms off it.
The time owed during seven frames at 144 Hz, step 8 of 8. Showing Frame on the clock, Time owed (15.28 ms (0.92 step)), update() (2 steps run). In focus: update().adds the frameone whole stepFrame on the clock6.94 ms eachTime owed15.28 ms (0.92 step)update()2 steps run

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Frame 1. A display at 144 frames a second shows a frame every 6.94 ms. The loop adds the first frame to the time it owes the simulation. 6.94 ms is less than one step of 16.67 ms, so no update runs before the frame is drawn.
  2. Frame 2. The second frame adds another 6.94 ms. The time owed, 13.89 ms, is still short of a whole step, so the jump stands still for a second frame.
  3. Frame 3: a step is owed. The third frame brings the time owed to 20.83 ms, more than one step. The while loop in frame() now has work to do.
  4. The first update. update() advances the jump by exactly 1/60 s, and the loop takes one step, 16.67 ms, off the time owed. 4.17 ms is left, less than a step, so the loop stops and the frame is drawn.
  5. Frame 4. Frame 4 adds 6.94 ms, and 11.11 ms are owed. No whole step, no update.
  6. Frame 5: the second update. Frame 5 brings 18.06 ms, so the second update runs and 1.39 ms stay owed. No frame of this run ever owes two whole steps at once.
  7. Frames 6 and 7. Frame 6 brings the time owed to 8.33 ms and frame 7 to 15.28 ms, both short of a step, so neither runs an update.
  8. 2.4 frames per step. Seven frames ran two updates. At 144 frames a second against 60 steps, an update falls on every 2.4 frames on average, and over the whole run 1,440 frames showed exactly 600 steps.

Seven frames, two updates: at 144 frames a second against 60 steps, an update falls on every 2.4 frames on average, and no frame of the run ran more than one.

Key idea

Real time only decides how many updates a frame runs. Every update advances the game by the same 1/60 s, so every frame rate computes the same states.

Checkpoint

Check: Whole steps from the time owed

  1. Question 1 of 2 A 50 Hz display shows a frame every 20 ms, and the loop of loop.h starts with nothing owed. How many updates of 1/60 s have run after its third frame?

    Type a number.

    A hint

    Add 20 ms per frame and take 16.67 ms off for each whole step.

    Show the answer: Question 1 of 2

    Answer: 3 updates

    Frame 1: 20 ms owed, one update, 3.33 ms left. Frame 2: 23.33 ms, one update, 6.67 ms left. Frame 3: 26.67 ms, one update, 10 ms left. Three updates in 60 ms, and the 10 ms still owed is 0.6 of a step, the alpha of frame 3.

  2. Question 2 of 2 Why does loop.h keep the time owed in the common type of nanoseconds and 1/60 s instead of a double of seconds?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A frame and a step are both whole numbers of its tick, so adding and subtracting them is exact however long the game runs

    1/60 s is 16,666,666.67 ns, not a whole number of nanoseconds. The common type's tick is the greatest common divisor of the two periods, 1/3,000,000,000 s, and both durations are whole numbers of it, so the time owed never collects rounding errors.

Drawing between two states hides the steps

At 144 fps most frames run no update at all. The table at the end of the run shows what drawing the newest state would look like: its height stays 0 in frames 1 and 2, 0.0806 m in frames 3 and 4 and 0.1585 m in frames 5 to 7, so the jump would stand still for two or three frames and then leap, a stutter on the smoothest display. So frame() returns alpha, the share of a step that is still owed, and the drawing code blends the last two states by it, an Drawing a frame between the last two simulation states, a share alpha of the way from the previous one to the newest, so that motion looks smooth although updates fall on only some frames.:

drawn=previous+(newest−previous)×α\text{drawn} = \text{previous} + (\text{newest} - \text{previous}) \times \alpha

In frame 3, alpha is 0.25 and the previous height 0, so the frame draws a quarter of 0.0806 m, 0.0202 m. The same seven frames from the recorded run:

Frame alpha Newest state Drawn
1 0.42 0.0000 m 0.0000 m
2 0.83 0.0000 m 0.0000 m
3 0.25 0.0806 m 0.0202 m
4 0.67 0.0806 m 0.0537 m
5 0.08 0.1585 m 0.0871 m
6 0.50 0.1585 m 0.1195 m
7 0.92 0.1585 m 0.1520 m

The newest state moves in two frames of seven; from frame 3 on, the drawn height rises in every frame, as alpha moves on by 60/144, about 0.42 of a step, each time. Blending has a cost in delay: what the player sees lags the newest state by up to one step, 16.7 ms. The alternative, extrapolation, pushes the newest state forward by alpha of a step at its current speed; it removes that delay but guesses wrong whenever something changes direction, as at every bounce.

Refresh Rate Test Measure your display's refresh rate, then work out how many 1/60 s steps one of its frames lasts.

Checkpoint

Check: Drawing between two states

  1. Question 1 of 2 In frame 6 of the 144 fps run, alpha is 0.50, the previous state's height is 0.0806 m and the newest is 0.1585 m. What height does the frame draw?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0.1195 m (anything from 0.119 to 0.12 counts)

    previous + (newest − previous) × alpha = 0.0806 + (0.1585 − 0.0806) × 0.50 ≈ 0.1195 m, the height the recorded run printed for frame 6. The newest state has not changed since frame 5, but the drawn height has.

  2. Question 2 of 2 What does drawing between the last two states cost?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: What the player sees lags the newest state by up to one step, 16.7 ms

    The drawn picture is alpha of the way from the previous state to the newest, so it lags the newest state by up to one step. Extrapolation removes the delay by guessing ahead, and guesses wrong at every change of direction.

The real clock must be one that never runs backwards

In the game the same loop runs on std::chrono::steady_clock, a A clock whose time points never decrease and advance at a steady rate relative to real time. Setting the computer's date or time does not move it.: the standard defines it as a clock whose time points never decrease and advance at a steady rate. The wall clock, system_clock, can be set back or forward by the user or by time synchronisation, and a loop that measured frames with it could one day see a frame of minus three seconds:

realtime.cpp C++ · realtime.cpp
// The same loop on the real clock, std::chrono::steady_clock, with frames that take about
// 7 ms to draw (the sleep stands in for the drawing).
#include <chrono>
#include <print>
#include <thread>
#include "jumper.h"
#include "loop.h"

struct JumpSim {
    Jumper body;
    void update() { body.advance(game::step_seconds); }
};

int main() {
    using clock = std::chrono::steady_clock;
    static_assert(clock::is_steady, "a game loop needs a clock that never jumps");

    game::FixedStepLoop<clock> loop;
    JumpSim sim;
    sim.body.jump();
    const auto start = clock::now();
    int frames = 0;
    while (loop.steps() < 60) {  // one second of game time
        loop.frame(sim);
        std::this_thread::sleep_for(std::chrono::milliseconds{7});  // "drawing"
        ++frames;
    }
    const std::chrono::duration<double, std::milli> real = clock::now() - start;
    std::println("60 steps of game time took {:.0f} ms of real time, in {} frames", real.count(), frames);
    std::println("peak of the jump: {:.4f} m", sim.body.peak);
}
One second of game time on the real clock Python · realtime_session.py
from session import run

# realtime.cpp runs the loop on std::chrono::steady_clock for one second of game time.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off realtime.cpp -o realtime && ./realtime
g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off realtime.cpp -o realtime-gcc && ./realtime-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off realtime.cpp -o realtime && ./realtime
60 steps of game time took 1009 ms of real time, in 119 frames
peak of the jump: 1.2329 m
$ g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off realtime.cpp -o realtime-gcc && ./realtime-gcc
60 steps of game time took 1011 ms of real time, in 117 frames
peak of the jump: 1.2329 m

This output changes from run to run: the frame count and the real time change from run to run, with how long the operating system takes to wake the thread

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 realtime_session.py

A frame took about 8.5 ms, the 7 ms sleep plus the time the system needed to wake the thread again, and one second of game time took a little over a second of real time. Those numbers change from run to run. The peak did not: 1.2329 m, the same as in the runs without a real clock, because the simulation never reads the clock.

Checkpoint

Check: A clock that never runs backwards

  1. Question 1 of 2 The loop of loop.h measures frames with system_clock, and the computer's clock is set back 3 s between two frames. What does frame() do with that frame?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It adds about −3 s to the time owed, so no update runs until about 3 s of later frames have paid the debt back, and the game stands still

    With system_clock, now − previous is about −3 s. std::min against 250 ms keeps it, the time owed falls to about −3 s, and while it is negative the while loop runs no update. With steady_clock that cannot happen, because its time points never decrease.

  2. Question 2 of 2 What does the C++ standard promise about std::chrono::steady_clock?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its time points never decrease, and they advance at a steady rate relative to real time

    A steady clock's time points never decrease as real time passes and advance at a steady rate, so the time between two frames measured with it is never negative and is not moved by a change of the date or time.

A limit on frame time turns a spiral into slow motion

The loop assumes that an update costs less real time than the step it simulates. When it costs more, every frame owes more steps than the one before, so the frames get longer and owe even more: the What a fixed-step loop does when one update costs more real time than the step it simulates: each frame owes more steps than the one before, until the game stops responding.. spiral.cpp makes each update cost 20 ms of a manual clock, against a step of 16.7 ms:

spiral.cpp C++ · spiral.cpp
// When one update takes longer than the 1/60 s it simulates: each frame owes more steps than
// the one before. A clock moved by hand plays the part of the real one.
#include <chrono>
#include <cstdint>
#include <print>
#include "loop.h"

using std::chrono::milliseconds;
using std::chrono::nanoseconds;

struct ManualClock {
    using rep = std::int64_t;
    using period = std::nano;
    using duration = nanoseconds;
    using time_point = std::chrono::time_point<ManualClock>;
    static constexpr bool is_steady = true;
    static inline time_point current{};
    static time_point now() { return current; }
};

// An update that costs 20 ms of real time, more than the 16.7 ms step it simulates.
struct SlowSim {
    void update() { ManualClock::current += milliseconds{20}; }
};

void run(const char* name, nanoseconds max_frame) {
    game::FixedStepLoop<ManualClock> loop{max_frame};
    SlowSim sim;
    ManualClock::current += milliseconds{17};  // the first frame
    std::print("  {:<12}", name);
    const auto start = ManualClock::now();
    std::int64_t steps_before = 0;
    for (int frame = 1; frame <= 20; ++frame) {
        loop.frame(sim);
        std::print(" {:>2}", loop.steps() - steps_before);
        steps_before = loop.steps();
        ManualClock::current += milliseconds{2};  // drawing the frame
    }
    const std::chrono::duration<double> real = ManualClock::now() - start;
    std::println("   {:4.1f} s real, {:4.2f} s game", real.count(), loop.steps() / 60.0);
}

int main() {
    std::println("steps run in each of 20 frames:");
    run("no limit", nanoseconds::max());
    run("limit 100 ms", milliseconds{100});
}
Steps per frame, without and with a limit Python · spiral_session.py
from session import run

# spiral.cpp: updates that cost more than the time they simulate, without and with a limit.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra spiral.cpp -o spiral && ./spiral
g++-16 -std=c++23 -O2 -Wall -Wextra spiral.cpp -o spiral-gcc && ./spiral-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra spiral.cpp -o spiral && ./spiral
steps run in each of 20 frames:
  no limit      1  1  1  1  2  2  3  4  4  5  7  8 10 12 14 17 21 25 30 36    4.1 s real, 3.40 s game
  limit 100 ms  1  1  1  1  2  2  3  4  4  5  6  6  6  6  6  6  6  6  6  6    1.7 s real, 1.40 s game
$ g++-16 -std=c++23 -O2 -Wall -Wextra spiral.cpp -o spiral-gcc && ./spiral-gcc
steps run in each of 20 frames:
  no limit      1  1  1  1  2  2  3  4  4  5  7  8 10 12 14 17 21 25 30 36    4.1 s real, 3.40 s game
  limit 100 ms  1  1  1  1  2  2  3  4  4  5  6  6  6  6  6  6  6  6  6  6    1.7 s real, 1.40 s game

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 spiral_session.py

Without a limit, the steps per frame grew from 1 to 36 within 20 frames, and the last frame took more than 0.7 s: a real game would stop responding. Step through how the debt feeds itself, and then how the limit stops it:

Interactive · step by step How slow updates make every frame longer Step through the frames of spiral.cpp, where one update costs 20 ms of real time and simulates 16.67 ms, first without a limit, then with frames counted as 100 ms at most.
How slow updates make every frame longer, step 6 of 6. Showing Frame length (122 ms), Time owed (at most 100 ms a frame), Updates this frame (6 updates a frame), Limit: 100 ms. In focus: Limit: 100 ms, Updates this frame.added to the debtwhole stepslengthen the next framecaps what a frame addsFrame length122 msTime owedat most 100 ms a frameUpdates this frame6 updates a frameLimit: 100 mscounted at mostat most 100 ms

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Frame 1: one update. The first frame lasted 17 ms, so one step is owed and one update runs. In spiral.cpp that update costs 20 ms of real time, more than the 16.67 ms of game time it simulates.
  2. Frames 2 to 4. Each of the next frames lasts that update plus 2 ms of drawing, 22 ms, but one update pays back only 16.67 ms of it. The time left over grows by 5.33 ms a frame, to 16.33 ms after frame 4.
  3. Frame 5: two updates. Frame 5 brings the time owed to 38.33 ms, two steps, so two updates run and cost 40 ms. Frame 6 therefore lasts 42 ms and owes two steps again.
  4. A debt that feeds itself. Every update lengthens the next frame by 20 ms but pays back only 16.67 ms of game time, so each frame owes about 1.2 times the updates of the one before. Frame 7 runs 3 updates, frame 8 runs 4, frame 13 runs 10.
  5. Frame 20: 36 updates. Frame 20 runs 36 updates, 720 ms of work, so with its drawing the next frame lasts 722 ms. The game has stopped responding, and every frame makes the next one longer still. After 20 frames, 4.1 s of real time had passed for 3.40 s of game time.
  6. With a limit: slow motion. Counted as 100 ms at most, a frame owes at most 6 steps. From frame 11 on each frame runs 6 updates, 122 ms of real time with the drawing, for 100 ms of game time. The game runs at 82 % speed, 1.40 s of game time in 1.7 s, and keeps drawing and reading input.

With frames counted as 100 ms at most, the loop settled at 6 steps a frame and the game ran in slow motion, 1.40 s of game time in 1.7 s, while it kept drawing and reading input. The limit of 250 ms in loop.h also absorbs single long frames, such as a stop at a breakpoint, which would otherwise be paid back with a burst of hundreds of steps. A limit only contains the problem: the cure is an update that costs less than its step, or a longer step.

Checkpoint

Check: The spiral of death and its limit

  1. Question 1 of 2 Each update costs 20 ms of real time, one step is 1/60 s, and a frame counts as 100 ms at most. How many updates does each frame run once the loop has settled?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 6 updates

    100 ms ÷ 16.67 ms = 6 steps. Every frame then lasts 6 × 20 ms + 2 ms of drawing = 122 ms, which the limit counts as 100 ms, so the debt stops growing: spiral.cpp printed 6 updates a frame from frame 11 on.

  2. Question 2 of 2 Updates cost 20 ms and the step is 16.7 ms. Which change removes the cause of the spiral instead of containing it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An update that costs less real time than its step, or a longer step

    The spiral comes from an update that costs more than the game time it simulates. A limit only caps what one frame may add, which turns the spiral into slow motion; making the update cheaper, or the step longer, removes it.

Two builds agree only if they round the same way

A fixed step is necessary for determinism, but not enough. Everything the simulation reads must be the same on every run: the inputs, recorded by step number rather than by frame, the random numbers, and never a clock, the timing of threads or a memory address. The arithmetic must be the same too, and there compilers differ. desync.cpp is a fixed-step simulation of a ball bouncing around a round bumper, built five ways:

desync.cpp C++ · desync.cpp
// One fixed-step simulation, built in several ways: a ball under gravity in a 10 m box, with a
// round bumper of radius 1.5 m in the middle. It prints where the ball is and a fingerprint of
// the bits of its state, every 20 s of game time.
#include <bit>
#include <cmath>
#include <cstdint>
#include <initializer_list>
#include <print>

struct Ball {
    double x = 2.0, y = 8.0;    // metres
    double vx = 3.0, vy = 0.0;  // metres per second
};

constexpr double dt = 1.0 / 60.0;

void update(Ball& b) {
    const double dvy = -9.81 * dt;  // gravity
    b.vy += dvy;
    const double dx = b.vx * dt;
    b.x += dx;
    const double dy = b.vy * dt;
    b.y += dy;
    // The walls of the box: mirror the position back inside and turn the speed round.
    if (b.x < 0.0) { b.x = -b.x; b.vx = -b.vx; }
    if (b.x > 10.0) { b.x = 20.0 - b.x; b.vx = -b.vx; }
    if (b.y < 0.0) { b.y = -b.y; b.vy = -b.vy; }
    if (b.y > 10.0) { b.y = 20.0 - b.y; b.vy = -b.vy; }
    // The bumper: inside it, reflect the velocity off its surface (the next lesson derives this).
    const double cx = b.x - 5.0, cy = b.y - 5.0;
    const double d2 = cx * cx + cy * cy;
    if (d2 < 1.5 * 1.5) {
        const double d = std::sqrt(d2);
        const double nx = cx / d, ny = cy / d;
        const double towards = b.vx * nx + b.vy * ny;
        if (towards < 0.0) {
            b.vx -= 2.0 * towards * nx;
            b.vy -= 2.0 * towards * ny;
        }
    }
}

std::uint64_t fingerprint(const Ball& b) {
    std::uint64_t h = 14695981039346656037u;  // FNV-1a over the bytes of the four doubles
    for (double v : {b.x, b.y, b.vx, b.vy}) {
        for (int shift = 0; shift < 64; shift += 8) {
            h ^= (std::bit_cast<std::uint64_t>(v) >> shift) & 0xff;
            h *= 1099511628211u;
        }
    }
    return h;
}

int main() {
    Ball b;
    for (int step = 1; step <= 3600; ++step) {
        update(b);
        if (step % 1200 == 0) std::println("  {:>2} s  x {:6.3f}  y {:6.3f}  state {:016x}", step / 60, b.x, b.y, fingerprint(b));
    }
}
One simulation, five builds Python · desync_session.py
from session import run

# desync.cpp built five ways: each compiler's defaults, GCC without optimisation (a debug
# build), then both compilers with floating-point contraction switched off.
run("""
clang++ -std=c++23 -O2 desync.cpp -o desync-clang && ./desync-clang
g++-16 -std=c++23 -O2 desync.cpp -o desync-gcc && ./desync-gcc
g++-16 -std=c++23 -O0 desync.cpp -o desync-gcc-debug && ./desync-gcc-debug
clang++ -std=c++23 -O2 -ffp-contract=off desync.cpp -o desync-clang-off && ./desync-clang-off
g++-16 -std=c++23 -O2 -ffp-contract=off desync.cpp -o desync-gcc-off && ./desync-gcc-off
""")

Output

$ clang++ -std=c++23 -O2 desync.cpp -o desync-clang && ./desync-clang
  20 s  x  8.997  y  5.959  state c4f761477a76f53a
  40 s  x  1.797  y  5.817  state 99553e16824e4dae
  60 s  x  0.821  y  6.178  state 035ed8ddfecf82f8
$ g++-16 -std=c++23 -O2 desync.cpp -o desync-gcc && ./desync-gcc
  20 s  x  8.997  y  5.959  state c50e3b9626d7801d
  40 s  x  0.428  y  2.537  state 6939d56632283da6
  60 s  x  4.904  y  2.033  state e47633b12ddbfd05
$ g++-16 -std=c++23 -O0 desync.cpp -o desync-gcc-debug && ./desync-gcc-debug
  20 s  x  8.997  y  5.959  state f547ead3706d241b
  40 s  x  1.867  y  5.767  state 852a62df52f16647
  60 s  x  4.833  y  7.154  state fa37e31a1cbe36ca
$ clang++ -std=c++23 -O2 -ffp-contract=off desync.cpp -o desync-clang-off && ./desync-clang-off
  20 s  x  8.997  y  5.959  state f547ead3706d241b
  40 s  x  1.867  y  5.767  state 852a62df52f16647
  60 s  x  4.833  y  7.154  state fa37e31a1cbe36ca
$ g++-16 -std=c++23 -O2 -ffp-contract=off desync.cpp -o desync-gcc-off && ./desync-gcc-off
  20 s  x  8.997  y  5.959  state f547ead3706d241b
  40 s  x  1.867  y  5.767  state 852a62df52f16647
  60 s  x  4.833  y  7.154  state fa37e31a1cbe36ca

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 desync_session.py

Three builds agree, and the other two each play a game of their own:

Build Ball after 40 s Ball after 60 s
Clang -O2, its defaults (1.797, 5.817) (0.821, 6.178)
GCC -O2, its defaults (0.428, 2.537) (4.904, 2.033)
GCC -O0, and both with -ffp-contract=off (1.867, 5.767) (4.833, 7.154)

After 20 s every build puts the ball at the same place to the millimetre, but the fingerprints differ already. At 40 s the GCC build’s ball is 3.5 m from the others, and at 60 s the three versions of the ball are 4 to 6 m apart, because each bounce off the round bumper magnifies a difference in the last bit. The cause is A compiler joining a multiplication and an addition into one fused multiply-add instruction, which rounds once instead of twice, so the last bit of the result can differ.. GCC’s default for C++ is -ffp-contract=fast, which fuses across statements; Clang’s default is on, which fuses only within one statement. GCC without optimisation fused nothing here, so its debug build and its release build disagree as well. With -ffp-contract=off, every result is rounded the same way, and the builds agree.

IEEE 754 Floating-Point Converter See how a double stores 0.1 as the nearest binary fraction: one rounding more or less changes the last bit.

Checkpoint

Check: The same rounding on every build

  1. Question 1 of 2 Which builds of desync.cpp ended in the same state, bit for bit?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: GCC without optimisation, and both compilers with -ffp-contract=off

    The fingerprints after 20 s fall into three groups: Clang's defaults, GCC's defaults, and the three builds that contract nothing (GCC -O0, and both compilers with -ffp-contract=off). Only the last group agrees.

  2. Question 2 of 2 After 20 s all five builds put the ball at the same place to the millimetre, yet after 60 s they are metres apart. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each bounce off the round bumper turns a difference in the last bit into a larger difference in direction

    The fingerprints already differed after 20 s, because a fused multiply-add rounds once where two operations round twice. Bounces off the round bumper magnify that difference until, after 60 s, the balls are 4 to 6 m apart.

Random numbers and update order must match too

Random numbers hide the same trap one level up. The standard specifies the engines’ algorithms exactly, so a std::mt19937, a random number engine: An object such as std::mt19937 that keeps a state and returns the next raw number of a fixed sequence each time it is called. The C++ standard defines each engine's algorithm exactly, so one seed gives the same sequence on every library. More in “Random numbers in C++: engines, distributions, seeds”, produces the same numbers everywhere from a given seed, and the standard even states that the 10,000th value of a default-constructed std::mt19937 is 4123659995. The algorithms of the distributions it leaves to each library:

dice.cpp C++ · dice.cpp
// The same seed through std::mt19937, through std::uniform_int_distribution, and through a
// mapping written out in full.
#include <cstdint>
#include <print>
#include <random>

// A roll from 1 to 6 that every standard library computes the same way: keep only engine
// values below the largest multiple of 6 that fits in 32 bits, so every face is equally likely.
int roll(std::mt19937& gen) {
    constexpr std::uint32_t limit = 4'294'967'292u;  // 6 * 715'827'882
    std::uint32_t x = 0;
    do {
        x = static_cast<std::uint32_t>(gen());
    } while (x >= limit);
    return static_cast<int>(x % 6) + 1;
}

int main() {
    std::mt19937 engine{42};
    std::print("engine          ");
    for (int i = 0; i < 4; ++i) std::print(" {}", engine());

    std::mt19937 for_library{42};
    std::uniform_int_distribution<int> die{1, 6};
    std::print("\ndistribution    ");
    for (int i = 0; i < 12; ++i) std::print(" {}", die(for_library));

    std::mt19937 for_roll{42};
    std::print("\nroll()          ");
    for (int i = 0; i < 12; ++i) std::print(" {}", roll(for_roll));
    std::println("");
}
Seed 42 with libc++ and with libstdc++ Python · dice_session.py
from session import run

# dice.cpp with Apple clang and libc++, then with GCC 16 and libstdc++.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra dice.cpp -o dice && ./dice
g++-16 -std=c++23 -O2 -Wall -Wextra dice.cpp -o dice-gcc && ./dice-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra dice.cpp -o dice && ./dice
engine           1608637542 3421126067 4083286876 787846414
distribution     4 5 3 5 5 2 3 3 3 5 4 3
roll()           1 6 5 5 1 6 5 3 5 6 5 5
$ g++-16 -std=c++23 -O2 -Wall -Wextra dice.cpp -o dice-gcc && ./dice-gcc
engine           1608637542 3421126067 4083286876 787846414
distribution     3 5 6 2 5 5 4 4 1 3 1 1
roll()           1 6 5 5 1 6 5 3 5 6 5 5

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dice_session.py

Both libraries drew the same engine values from seed 42, and rolled different dice from them through std::uniform_int_distribution, a random number distribution: An object such as std::uniform_int_distribution<int>(1, 6) that turns an engine's raw numbers into numbers of the range and shape you want. The standard says what it must produce, not how, so libraries may give different numbers. More in “Random numbers in C++: engines, distributions, seeds”. roll() maps the engine’s values to faces with arithmetic that the program spells out, so it rolls the same everywhere. For a simulation that must replay bit for bit:

  • advance it in fixed steps, with the inputs recorded by step number;
  • take random numbers from a named engine such as std::mt19937, seeded and saved with the game, through your own mapping instead of the std:: distributions;
  • never read a clock, the frame time or the timing of threads inside update();
  • process objects in an order the program defines, never in the order of a std::unordered_map, which depends on the library’s hash functions, and never in an order that depends on memory addresses;
  • build every copy with the same floating-point options: -ffp-contract=off, and never -ffast-math or -Ofast, which allow the compiler to reorder floating-point arithmetic. Alternatively, keep the state in integers (fixed point), which every compiler computes in the same way.

Key idea

A simulation replays bit for bit only when everything it reads is the same on every run: fixed steps, inputs by step, portable random numbers, a defined order and the same rounding on every build.

A deterministic simulation is easy to test. Record the inputs of a session, play them back without a window, and compare a fingerprint of the final state with the one recorded. Play the same inputs with frames of 30, 60 and 144 fps and require equal fingerprints: any place where the frame rate leaks into the simulation fails that test.

Checkpoint

Check: The same random numbers and order

  1. Question 1 of 2 Seeded with 42, both libraries' std::mt19937 returned 1608637542 first, yet their std::uniform_int_distribution dice differ. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The standard fixes each engine's algorithm but leaves each distribution's algorithm to the library

    The C++ standard specifies the engines exactly and the distributions only by the results they must produce, so the same engine values turn into different dice. roll() spells its mapping out and rolls the same everywhere.

  2. Question 2 of 2 Which of these can make two runs of the same simulation, with the same recorded inputs, end in different states?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Updating objects in the iteration order of a std::unordered_map keyed by pointers
    • Reading std::chrono::steady_clock::now() inside update()
    • Rolling dice with std::uniform_int_distribution on two different standard libraries

    The clock, the library's distribution algorithm and the order of an unordered map keyed by addresses all differ between runs or machines. The frame rate does not matter when the simulation advances in fixed steps with inputs recorded by step: fixed_step.cpp ended in the same state at four frame rates.

Practise: a replay that ignores the frame rate

ball.h is a ball in a room 4 m wide and 3 m high, ready to use. replay.h plays a recording of inputs, one per step, through a fixed-timestep loop, and its two functions are yours to write. replay_tests.cpp replays the same recording with four frame patterns and checks the result after 600 steps:

The exercise: the simulation, the starter and its sample tests

C header (ball.h) · ball.h

// The exercise's simulation, ready to use: a ball in a room 4 m wide and 3 m high.
#pragma once

struct Vec {
    double x = 0.0, y = 0.0;
    bool operator==(const Vec&) const = default;
};

// What the player did during one step: a kick adds speed, in metres per second.
struct Input {
    double kick_x = 0.0, kick_y = 0.0;
};

struct Ball {
    Vec position{1.0, 2.0};  // metres; the floor is y = 0, the walls x = 0 and x = 4
    Vec velocity{};          // metres per second
    bool operator==(const Ball&) const = default;
};

inline constexpr double step_seconds = 1.0 / 60.0;

// One step of 1/60 s: the kick and gravity change the speed, the speed moves the ball, and a
// ball that went through a wall, the floor or the ceiling is mirrored back, keeping 80 % of its speed.
inline void step(Ball& b, const Input& in) {
    b.velocity.x += in.kick_x;
    b.velocity.y += in.kick_y - 9.81 * step_seconds;
    b.position.x += b.velocity.x * step_seconds;
    b.position.y += b.velocity.y * step_seconds;
    if (b.position.x < 0.0) { b.position.x = -b.position.x; b.velocity.x *= -0.8; }
    if (b.position.x > 4.0) { b.position.x = 8.0 - b.position.x; b.velocity.x *= -0.8; }
    if (b.position.y < 0.0) { b.position.y = -b.position.y; b.velocity.y *= -0.8; }
    if (b.position.y > 3.0) { b.position.y = 6.0 - b.position.y; b.velocity.y *= -0.8; }
}

C header (replay.h) · replay.h

// The exercise: drive ball.h's step() with a fixed-timestep loop. Fill in the two TODOs.
#pragma once
#include <chrono>
#include <cstdint>
#include <ratio>
#include <type_traits>
#include <utility>
#include <vector>
#include "ball.h"

class Replay {
public:
    // At most this many steps in one frame.
    static constexpr int max_steps = 8;

    // inputs[n] is what the player did during step n; the replay ends with the recording.
    explicit Replay(std::vector<Input> inputs) : inputs_{std::move(inputs)} {}

    // One frame that lasted `elapsed`. Adds it to the time owed, then runs steps of exactly
    // 1/60 s while a whole step is owed, at most max_steps of them and none past the end of the
    // recording; if a whole step is still owed after that, the time owed is set to zero (the game
    // slows down instead of catching up). Step n uses inputs[n], and before each step the ball is
    // kept as it was, for drawn(). Returns the number of steps run.
    int frame(std::chrono::nanoseconds elapsed) {
        // TODO 1
        (void)elapsed;
        return 0;
    }

    // Where to draw the ball: between its position one step ago and its position now, alpha of
    // the way to now, where alpha is the time owed divided by the length of one step.
    Vec drawn() const {
        // TODO 2
        return ball_.position;
    }

    const Ball& ball() const { return ball_; }
    std::int64_t steps() const { return steps_; }

private:
    using Step = std::chrono::duration<std::int64_t, std::ratio<1, 60>>;

    std::vector<Input> inputs_;
    Ball ball_{};
    Ball previous_{};
    std::int64_t steps_ = 0;
    std::common_type_t<std::chrono::nanoseconds, Step> owed_{0};
};

C++ · replay_tests.cpp

// Sample tests for replay.h. Build: clang++ -std=c++23 -Wall -Wextra replay_tests.cpp -o replay_tests
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <print>
#include <random>
#include <vector>
#include "replay.h"

using std::chrono::nanoseconds;

int passed = 0;
int failed = 0;

void expect(bool ok, const char* what) {
    if (ok) {
        ++passed;
    } else {
        ++failed;
        std::println("FAIL {}", what);
    }
}

bool near(double a, double b) { return std::abs(a - b) < 1e-9; }

// The recorded game: a kick to the right at step 0, upwards at step 200, back to the left at 400.
std::vector<Input> recording() {
    std::vector<Input> inputs(600);
    inputs[0] = {2.0, 0.0};
    inputs[200] = {0.0, 6.0};
    inputs[400] = {-3.0, 1.0};
    return inputs;
}

// Frames at a steady rate: frame i ends at the first nanosecond at or after i / fps seconds.
std::vector<nanoseconds> steady(std::int64_t fps, std::int64_t count) {
    const auto end = [fps](std::int64_t i) { return (i * 1'000'000'000 + fps - 1) / fps; };
    std::vector<nanoseconds> frames;
    for (std::int64_t i = 1; i <= count; ++i) frames.push_back(nanoseconds{end(i) - end(i - 1)});
    return frames;
}

// Frames between 4 and 40 ms long, the same ones on every run.
std::vector<nanoseconds> irregular(int count) {
    std::mt19937 gen{11};
    std::vector<nanoseconds> frames;
    for (int i = 0; i < count; ++i) frames.push_back(nanoseconds{4'000'000 + gen() % 36'000'001});
    return frames;
}

// Plays every frame; returns the number of steps each one ran.
std::vector<int> play(Replay& game, const std::vector<nanoseconds>& frames) {
    std::vector<int> per_frame;
    for (const nanoseconds f : frames) per_frame.push_back(game.frame(f));
    return per_frame;
}

int main() {
    Replay at60{recording()};
    const auto steps60 = play(at60, steady(60, 600));
    expect(at60.steps() == 600 && std::ranges::all_of(steps60, [](int n) { return n == 1; }), "600 frames at 60 fps run one step each");

    Replay at144{recording()};
    const auto steps144 = play(at144, steady(144, 1440));
    expect(at144.steps() == 600 && std::ranges::max(steps144) == 1, "frames at 144 fps run 600 steps, never two in one frame");

    Replay at30{recording()};
    play(at30, steady(30, 300));
    Replay uneven{recording()};
    play(uneven, irregular(500));
    expect(at30.steps() == 600 && uneven.steps() == 600 && at30.ball() == at60.ball() && at144.ball() == at60.ball() && uneven.ball() == at60.ball(),
           "30 fps, 60 fps, 144 fps and irregular frames end in the same state, bit for bit");

    expect(near(at60.ball().position.x, 1.8546666666666578) && near(at60.ball().position.y, 0.0115683535717790),
           "after 600 steps the ball is at (1.854667, 0.011568)");

    Replay hitch{recording()};
    const int long_frame = hitch.frame(std::chrono::seconds{2});
    const int next_frame = hitch.frame(nanoseconds{16'666'667});
    expect(long_frame == Replay::max_steps && next_frame == 1, "a 2 s frame runs 8 steps, the cap, and the next frame 1");

    Replay drawn{recording()};
    drawn.frame(nanoseconds{37'500'000});  // two and a quarter steps
    Ball one{};
    step(one, recording()[0]);
    Ball two = one;
    step(two, recording()[1]);
    const Vec d = drawn.drawn();
    expect(near(d.x, one.position.x + (two.position.x - one.position.x) * 0.25) && near(d.y, one.position.y + (two.position.y - one.position.y) * 0.25),
           "after 2.25 steps of time the ball is drawn a quarter of the way from step 1 to step 2");

    std::println("{} of {} checks passed", passed, passed + failed);
    return failed == 0 ? 0 : 1;
}

Before you change anything, the starter fails every check:

The sample tests against the starter Python · replay_session.py
from session import run

# The sample tests against the starter code, before the TODOs are done.
run("""
clang++ -std=c++23 -Wall -Wextra replay_tests.cpp -o replay_tests && ./replay_tests
g++-16 -std=c++23 -Wall -Wextra replay_tests.cpp -o replay_tests-gcc && ./replay_tests-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra replay_tests.cpp -o replay_tests && ./replay_tests
FAIL 600 frames at 60 fps run one step each
FAIL frames at 144 fps run 600 steps, never two in one frame
FAIL 30 fps, 60 fps, 144 fps and irregular frames end in the same state, bit for bit
FAIL after 600 steps the ball is at (1.854667, 0.011568)
FAIL a 2 s frame runs 8 steps, the cap, and the next frame 1
FAIL after 2.25 steps of time the ball is drawn a quarter of the way from step 1 to step 2
0 of 6 checks passed
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra replay_tests.cpp -o replay_tests-gcc && ./replay_tests-gcc
FAIL 600 frames at 60 fps run one step each
FAIL frames at 144 fps run 600 steps, never two in one frame
FAIL 30 fps, 60 fps, 144 fps and irregular frames end in the same state, bit for bit
FAIL after 600 steps the ball is at (1.854667, 0.011568)
FAIL a 2 s frame runs 8 steps, the cap, and the next frame 1
FAIL after 2.25 steps of time the ball is drawn a quarter of the way from step 1 to step 2
0 of 6 checks passed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 replay_session.py

Edit replay.h until both compilers print 6 of 6 checks passed:

  1. In frame(), add elapsed to owed_, then loop while a whole Step{1} is owed, fewer than max_steps steps have run in this frame, and the recording has an input left. In each round keep previous_ = ball_, call step(ball_, inputs_[steps_]), subtract one step and count it.
  2. After the loop, if a whole step is still owed, set owed_ to zero, so that a two-second frame runs 8 steps and the next frame starts fresh.
  3. In drawn(), compute alpha as std::chrono::duration<double>(owed_) / Step{1}, and blend previous_ and ball_ with it.

The bit-for-bit check compares four replays with ==. It passes only when every frame pattern runs the same steps with the same inputs, which is the whole point of the loop.

Then practise on every section of the lesson. The session opens with your weakest idea, moves up a level each time an answer is right, and repeats a missed question before it ends.

Practice that adapts to you

Practise this lesson

19 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 19 A game moves its simulation forward by each frame's own duration. What happens to the same jump at 30 fps and at 144 fps?

    Choose one answer.

    Show the answer: Question 1 of 19

    Answer: It peaks lower at 30 fps, because each long update treats the speed as constant for a whole frame

    The error of each update grows with the length of the step. In variable_step.cpp the jump peaked at 1.1920 m at 30 fps and at 1.2569 m at 144 fps; the exact height is 1.2742 m. A fixed step gives every frame rate the same 1.2329 m.

  2. Question 2 of 19 At 60 fps the recorded jump peaked 4.1 cm below the exact height. With the frame time as the step, about how far below would it peak at 120 fps?

    Type a number.

    A hint

    The shortfall is about v × Δt ÷ 2, so it follows the step.

    Show the answer: Question 2 of 19

    Answer: 2.1 cm (anything from 1.9 to 2.3 counts)

    Halving the step halves the shortfall: 5 m/s × (1/120) s ÷ 2 ≈ 2.1 cm, half the 4.1 cm at 60 fps. A faster display still gives a different jump, only a smaller difference.

  3. Question 3 of 19 A ledge sits 1.22 m above the ground, and the character jumps at 5 m/s. With the frame time as the step, which players reach it, by the recorded peaks?

    Choose one answer.

    Show the answer: Question 3 of 19

    Answer: Players at 60 and 144 fps, but not those at 30 fps or with irregular frames

    The recorded peaks were 1.1920 m at 30 fps, 1.2329 m at 60, 1.2569 m at 144 and 1.2115 m with irregular frames. Whether the ledge can be reached depends on the player's hardware, which a fixed step prevents.

  4. Question 4 of 19 The display shows 144 frames a second and the simulation runs 60 steps a second. On average, how many frames pass per step?

    Type a number.

    Show the answer: Question 4 of 19

    Answer: 2.4 frames (anything from 2.35 to 2.45 counts)

    144 / 60 = 2.4. Most frames run no step at all and some run one, which is why the loop returns alpha and the frame is drawn between the last two states.

  5. Question 5 of 19 The time owed reaches 20.83 ms, and a step is 16.67 ms. What does frame() do before the frame is drawn?

    Choose one answer.

    Show the answer: Question 5 of 19

    Answer: It runs one update and keeps 4.17 ms owed for the next frame

    frame() runs updates while at least one whole step is owed: one here. The remaining 4.17 ms is 0.25 of a step, which becomes the frame's alpha, as in frame 3 of the 144 fps run.

  6. Question 6 of 19 Your loop's fingerprint after 600 steps differs between a 30 fps replay and a 144 fps replay of the same inputs. Where do you look first?

    Choose one answer.

    Show the answer: Question 6 of 19

    Answer: At update() and everything it calls, for a read of the frame time, alpha or a clock instead of the fixed step

    With a fixed step the frame rate decides only how many frames show the states. A fingerprint that depends on the frame rate means the frame time has leaked into the simulation: that is exactly what the replay test is for.

  7. Question 7 of 19 Why would drawing the newest state stutter on a 144 Hz display with 60 steps a second?

    Choose one answer.

    Show the answer: Question 7 of 19

    Answer: Most frames run no update, so the newest state stays the same for two or three frames and then jumps

    A step falls on every 2.4 frames, so the newest state changes in only some frames. Blending the last two states by alpha makes the drawn height move in every frame instead.

  8. Question 8 of 19 A ball falls onto the floor and bounces between two updates. Which way of drawing can show it below the floor for a frame?

    Choose one answer.

    Show the answer: Question 8 of 19

    Answer: Extrapolation, because it pushes the newest state forward at its current speed, which still points down

    Interpolation draws between two states the simulation computed, at the cost of up to one step of delay. Extrapolation removes the delay by guessing, and the guess is wrong whenever something changes direction.

  9. Question 9 of 19 realtime.cpp ran on steady_clock, and its frame count changed from run to run. Why did the peak stay at 1.2329 m?

    Choose one answer.

    Show the answer: Question 9 of 19

    Answer: update() never reads the clock, so only the number of frames depends on real time

    The loop turns real time into whole steps, and each step advances the jump by exactly 1/60 s. The real clock only decides when the steps run, so the result is the same as with the manual clock.

  10. Question 10 of 19 Why should a game loop not measure its frames with std::chrono::system_clock?

    Choose one answer.

    Show the answer: Question 10 of 19

    Answer: The user or time synchronisation can set it back or forward, so a frame could measure negative or far too long

    system_clock tells the time of day and moves when that time is corrected. Intervals need steady_clock, whose time points never decrease.

  11. Question 11 of 19 Each update takes 20 ms of real time, and the step it simulates is 1/60 s. What does a loop without a limit on frame time do?

    Choose one answer.

    Show the answer: Question 11 of 19

    Answer: Each frame owes more steps than the one before, so the frames keep getting longer until the game stops responding

    An update that costs more than its step makes the debt grow: spiral.cpp ran 1, 1, 1, 1, 2, 2, 3 … 36 steps per frame. Limiting the frame time to 100 ms settled it at 6 steps a frame, in slow motion.

  12. Question 12 of 19 With the 100 ms limit, each frame runs 6 updates of 20 ms plus 2 ms of drawing and simulates 100 ms of game time. At what percentage of real speed does the game run?

    Type a number.

    Show the answer: Question 12 of 19

    Answer: 82 % (anything from 81 to 83 counts)

    100 ms of game time per 122 ms of real time is 82 %, as spiral.cpp measured: 1.40 s of game time in 1.7 s. The game keeps responding, only more slowly.

  13. Question 13 of 19 You stop the game at a breakpoint for 10 s. What does loop.h's 250 ms limit do with that frame when the game resumes?

    Choose one answer.

    Show the answer: Question 13 of 19

    Answer: It counts the frame as 250 ms, so the loop runs 15 updates and goes on, instead of 600

    std::min(now − previous, max_frame) counts the 10 s frame as 250 ms, 15 steps. Game time falls 9.75 s behind real time, which is what a paused game should do.

  14. Question 14 of 19 The Clang and GCC builds of the same fixed-step simulation drift apart after a few seconds. Which change made them agree bit for bit in this lesson?

    Choose one answer.

    Show the answer: Question 14 of 19

    Answer: Building both with -ffp-contract=off, so that neither compiler fuses a multiplication and an addition

    GCC defaults to -ffp-contract=fast and Clang to on, so they fuse different operations into multiply-adds that round once instead of twice. GCC at -O0 did not fuse, but it still disagreed with Clang's default build; with -ffp-contract=off on both, all builds matched. -ffast-math makes it worse by allowing reordering.

  15. Question 15 of 19 A colleague wants to build the lockstep simulation with -ffast-math to gain speed. What do you answer?

    Choose one answer.

    Show the answer: Question 15 of 19

    Answer: No, because it lets the compiler reorder floating-point arithmetic, so two builds can round differently

    -ffast-math and -Ofast let the compiler reorder floating-point arithmetic. For a simulation that must replay bit for bit, build every copy with -ffp-contract=off and without them, or keep the state in fixed point.

  16. Question 16 of 19 Your simulation must replay bit for bit on builds from three compilers, and you cannot control all of their floating-point options. What does the lesson suggest?

    Choose one answer.

    Show the answer: Question 16 of 19

    Answer: Keep the state in integers (fixed point), which every compiler computes in the same way

    Where every build can use -ffp-contract=off and avoid -ffast-math, floating point can replay bit for bit. Where that cannot be guaranteed, fixed-point integers remove the question, because integer results do not depend on the compiler.

  17. Question 17 of 19 update() visits the enemies in the order of a std::unordered_map<Enemy*, State>. Why can two machines end in different states?

    Choose one answer.

    Show the answer: Question 17 of 19

    Answer: The order depends on the library's hash function and on the addresses, which differ between runs and machines

    Process objects in an order the program defines, such as by entity id, never in the order of an unordered map, which depends on the library's hash function and on memory addresses.

  18. Question 18 of 19 Which source of random numbers replays the same on every standard library?

    Choose one answer.

    Show the answer: Question 18 of 19

    Answer: A named engine such as std::mt19937, seeded and saved with the game, through a mapping the program spells out

    The standard specifies the engines exactly, so std::mt19937 with a saved seed gives the same values everywhere. roll() maps them to faces with arithmetic it spells out, so the dice match too.

  19. Question 19 of 19 How do you test, without a window, that nothing in update() depends on the frame rate?

    Choose one answer.

    Show the answer: Question 19 of 19

    Answer: Replay the same recorded inputs with frames of 30, 60 and 144 fps and require equal fingerprints of the final state

    A deterministic simulation turns the same inputs into the same state. Replaying recorded inputs at several frame rates and comparing fingerprints is the test, and the exercise below builds exactly that.

Interview questions

Fresher · Why do games update their physics with a fixed timestep instead of the measured frame time?

Because the result of a numerical integration depends on the step size. With the frame time as the step, the same jump reaches a different height at 30 fps than at 144 fps (1.19 m against 1.26 m in this lesson’s run), a long frame can carry an object through a wall, and no run can be repeated exactly. A fixed step such as 1/60 s makes every update the same: the loop adds the real frame time to the time it owes and runs whole steps from it, and the renderer interpolates between the last two states, so motion stays smooth at any display rate. The fixed step is also what makes replays and headless tests possible. A complete answer names the costs: the picture lags by up to one step, and updates slower than the step need a limit, or the loop spirals.

The module’s bank holds two design questions built on this loop. Try the opening move of each below, then read what the feedback says about your choice and the others.

Checkpoint

Your first decision

  1. Question 1 of 2 Brief: a lockstep game sends only inputs. After an update, players on the Windows and macOS builds fall out of sync within a minute. What do you do first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Hash the simulation state after every step on each machine and compare, to find the first step whose hashes differ

    A desync has a first step, and everything after it is noise. Per-step hashes find that step, hashes per system narrow it down, and a recording of the session reproduces it at one desk; only then do you choose a fix.

  2. Question 2 of 2 Brief: on phones that run at 25 fps, players cannot clear a jump that is easy at 60 fps. The loop uses each frame's length as its step. What do you show first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The same jump integrated with steps of 1/25 s and of 1/60 s, compared at the peak

    v × Δt ÷ 2 gives 5 × 0.04 ÷ 2 = 10 cm short at 25 fps against 4.2 cm at 60 fps. Showing that with numbers points straight at taking the physics step away from the frame time.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A multiplayer game sends only the players' inputs and runs the same fixed-step simulation on every machine. After an update, players on the Windows and macOS builds fall out of sync within a minute. How do you find the cause, and how do you stop it happening again?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    On low-end phones your game runs at 25 fps, and players there cannot clear a jump that is easy at 60 fps. The loop moves the physics forward by each frame's duration. Explain the bug, redesign the loop, and say what your design costs.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A variable timestep makes the physics depend on the frame: the same jump fell 8.2 cm short at 30 fps and 1.7 cm short at 144, about v × Δt ÷ 2.
  • A fixed-step loop adds each frame’s real time to the time it owes and runs whole 1/60 s steps from it, kept in an exact std::chrono duration.
  • Drawing between the last two states, alpha of the way, keeps motion smooth at the cost of up to one step of delay.
  • Frames are measured with std::chrono::steady_clock, and a limit on what one frame may add turns a spiral of death into slow motion.
  • Determinism also needs the same rounding on every build (-ffp-contract=off), your own mapping of random numbers, inputs by step and a defined order.
  • Replaying recorded inputs at several frame rates and comparing fingerprints tests all of it without a window.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.