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.
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:
// 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);
}
}; // 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.
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:
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
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:
// 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
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:
// 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();
} 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.
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- Frame 4. Frame 4 adds 6.94 ms, and 11.11 ms are owed. No whole step, no update.
- 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.
- 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.
- 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
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.:
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
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:
// 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);
} 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
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:
// 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});
} 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
// 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));
}
} 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.
Checkpoint
Check: The same rounding on every build
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:
// 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("");
} 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 thestd::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-mathor-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
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:
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:
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:
- In
frame(), addelapsedtoowed_, then loop while a wholeStep{1}is owed, fewer thanmax_stepssteps have run in this frame, and the recording has an input left. In each round keepprevious_ = ball_, callstep(ball_, inputs_[steps_]), subtract one step and count it. - 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. - In
drawn(), compute alpha asstd::chrono::duration<double>(owed_) / Step{1}, and blendprevious_andball_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.
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
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
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?
-
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.
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::chronoduration. - 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
- Working Draft, Programming Languages: C++ (N4950), 29.7.7 Class steady_clock (ISO/IEC JTC1/SC22/WG21)
- Working Draft N4950, 28.5.6 Engines with predefined parameters [rand.predef] (ISO/IEC JTC1/SC22/WG21 (draft rendered by the cppwp project))
- Working Draft N4950, 28.5.9.1 Random number distribution class templates [rand.dist.general] (ISO/IEC JTC1/SC22/WG21 (draft rendered by the cppwp project))
- Date and time library (cppreference.com)
- std::common_type<std::chrono::duration> (cppreference.com)
- Options That Control Optimization (GCC): -ffp-contract, -ffast-math (GNU Project)
- Clang Compiler User's Manual: -ffp-contract (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress