⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1941+71115f0abWhat's newOn an older Zig?

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.

examples/lane-dodger/src/sim/sim.zigstep
    /// 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.

examples/lane-dodger/src/main.zigadvance
/// 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.

examples/lane-dodger/src/main.zigmax_frame_time
/// 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;
examples/lane-dodger/src/render/draw.ziglerp
/// 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.

examples/lane-dodger/src/main.zigmain
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.