The Loop Takes No dt
The obvious game loop reads the clock and hands the elapsed time to the world.
while (running) {
const dt = getFrameTime();
world.update(dt); // do not do this
draw(world);
}
That costs three things.
The same inputs stop producing the same run. dt is whatever the machine
delivered. A 60 Hz laptop and a 165 Hz monitor step the world differently.
Adding 1/60 sixty times does not give the same number as adding 1/165 one
hundred and sixty-five times.
A slow frame becomes an advantage. This game scores survival. A frame that takes 50 ms advances the world 50 ms in one jump. Collision is tested at the ends of that jump rather than through it. Stutter, and you pass through a block.
Nothing replays. To report a crash, you have to record a video of it. A seed and a list of keypresses will not reproduce it on another machine.
Fixed steps
step takes no dt. It advances one tick, always the same size.
/// Advance one fixed tick.
///
/// There is deliberately no `dt` parameter. A variable timestep would make
/// the same inputs produce different runs on different machines, which
/// costs the replay tests below and, in a game scored on survival time,
/// makes a slow frame a gameplay advantage. The caller accumulates real
/// time and calls this a whole number of times; `src/main.zig` shows how.
pub fn step(w: *World, input: Input) void {
switch (w.phase) {
.ready => if (input.confirm) w.start(),
.playing => w.stepPlaying(input),
.dead => {
w.death_time += config.tick_dt;
w.player.x_prev = w.player.x;
w.driftEntities();
if (w.death_time >= config.death_hold and input.confirm) w.start();
},
}
}There is no way to call it wrong. A caller who wants half a tick cannot get one. A caller who wants ten ticks calls it ten times.
The tick rate is 120 Hz, which is above every common refresh rate, so the simulation is never what makes the game look choppy.
The accumulator
Real time arrives in uneven chunks. The loop saves it and spends it one tick at a time.
/// Advance a world by whole ticks, reacting to what each one reports.
fn advance(Input goes to the first tick of the frame and is empty for the rest. Presses are edge triggered. Handing one press to three ticks would cross three lanes on a single tap.
A frame is clamped before it reaches the accumulator.
/// Largest real-time step we will believe. Past this the game was paused, the
/// laptop was asleep, or a breakpoint was hit; catching up on ten seconds of
/// ticks would only bury the player.
const max_frame_time: f32 = 0.25;Without that clamp, a paused game or a laptop waking from sleep can hand the
loop ten seconds at once. That is 1200 ticks. Running them takes long enough to
produce another large dt, which produces more ticks. The game stops responding
instead of skipping forward. The usual name for this is the spiral of death.
The leftover
After the tick loop there is always less than one tick of time left over. Ignoring it makes the game step visibly at 120 Hz on a 165 Hz display.
So the renderer is told how far into the next tick it is. It draws between the last two states.
const alpha = self.accumulator / config.tick_dt;
/// Interpolate between the last two simulation ticks. Without this the game
/// visibly steps at 120 Hz on a 144 Hz display.
fn lerp(previous: f32, current: f32, alpha: f32) f32 {
return previous + (current - previous) * alpha;
}This is why Entity carries a y_prev that no rule ever reads. It is not
simulation state. The renderer needs it to draw a frame that falls between two
ticks.
One frame, not a loop
On the desktop the loop is a while. In a browser it cannot be. Emscripten
calls a function once per animation frame and never returns into your code, so
there is nowhere for a while loop to live and nothing for its locals to live
in.
So “one frame” and “keep doing frames” are separate. Game is a struct with a
frame method. Only the caller differs.
pub fn main() void {
run();
}The game state is a file scope variable rather than a local.
emscripten_set_main_loop never returns, so a local in main would be gone by
the first frame.
The split is worth making even for a game that will never ship to a browser. A frame you can call once is a frame you can call six hundred times with a bot at the controls and no window open. That is how the difficulty tests work.