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

The World

The simulation never works in pixels. It works in a fixed field of 360 by 640 units, and the renderer maps that onto whatever the window happens to be.

examples/lane-dodger/src/sim/config.zigfield_w
/// Play field, in simulation units. Portrait, because the game is a thumb game.
pub const field_w: f32 = 360;

Two things follow. Resizing the window cannot change the difficulty, because a wider window does not show more road. And a test can assert that a block is at y = 520 without opening anything.

Three lanes divide the width, so a lane is 120 units across.

examples/lane-dodger/src/sim/config.ziglaneCenter
/// Centre of a lane in field units.
pub fn laneCenter(lane: u8) f32 {
    return (@as(f32, @floatFromInt(lane)) + 0.5) * lane_w;
}

So laneCenter(0) is 60, laneCenter(1) is 180 and laneCenter(2) is 300. Those three numbers are the only x positions anything ever settles at.

The player sits at a fixed height near the bottom. The world comes to them.

examples/lane-dodger/src/sim/config.zigplayer_y
/// The player sits at a fixed height and the world comes to them.
pub const player_y: f32 = 520;

The player has two positions

One is the lane being steered towards. The other is where the body actually is.

examples/lane-dodger/src/sim/sim.zigPlayer
pub const Player = struct {
    /// The lane being steered towards. Changes the instant a key is pressed.
    lane: u8,
    /// Where the player actually is. Slides towards the lane centre, so a
    /// change of mind mid-slide works, and so clipping a block while crossing
    /// is a real crash.
    x: f32,
    x_prev: f32,
};

lane changes the instant a key is pressed. x slides towards the centre of that lane over lane_change_time. They disagree for about a tenth of a second after every press, and that gap is the whole feel of the game.

examples/lane-dodger/src/sim/config.ziglane_change_time
/// Seconds to slide one full lane. The single most important feel number in
/// the game: too slow and it is unfair, too fast and there is no commitment.
pub const lane_change_time: f32 = 0.11;

The slide runs at constant speed rather than easing in and out.

examples/lane-dodger/src/sim/sim.zigmovePlayer
    fn movePlayer(w: *World, dt: f32) void {
        w.player.x_prev = w.player.x;
        const target = config.laneCenter(w.player.lane);
        // Constant speed rather than an ease, so two lanes cost exactly twice
        // one lane. The fairness budget in config.zig depends on that.
        const rate = config.lane_w / config.lane_change_time;
        const delta = target - w.player.x;
        const max_move = rate * dt;
        w.player.x += std.math.clamp(delta, -max_move, max_move);
    }

Easing would look smoother. It would also make two lanes cost less than twice one lane, and the spacing rule is built on two lanes costing exactly twice one. Feel and fairness point in opposite directions here, and fairness wins.

Because x is continuous, the player genuinely occupies the space between lanes. Clipping a block while sliding is a real crash, not a rounding error.

Everything else in the field

Blocks and coins are the same shape of thing, separated by a tag.

examples/lane-dodger/src/sim/sim.zigEntity
pub const Entity = struct {
    kind: Kind,
    lane: u8,
    y: f32,
    /// Position at the end of the previous tick, so the renderer can
    /// interpolate between ticks instead of showing the simulation's stair-step.
    y_prev: f32,
    /// Blocks: has this one drawn level with the player yet? Near misses are
    /// scored once, on the tick it happens.
    passed: bool = false,
};

y_prev is not simulation state. The renderer needs it to draw between two ticks.

The world

examples/lane-dodger/src/sim/sim.zigWorld fields
pub const World = struct {
    phase: Phase,
    rng: Rng,
    seed: u64,

    entities: EntityPool,
    player: Player,
    events: EventBuffer,

    /// Seconds of play in the current run.
    time: f32,
    /// Units scrolled in the current run.
    distance: f32,
    /// Units until the next row is laid down.
    to_next_row: f32,
    /// Seconds since the crash.
    death_time: f32,

    score: u32,
    /// Score from coins and near misses. Distance score is derived, so that
    /// rounding cannot make the total drift.
    bonus: u32,
    combo: u32,
    coins: u32,
    best: u32,

    // ...
};

seed and best survive a restart. Everything else is cleared. score is derived rather than accumulated: distance points are recomputed from distance every tick, and only coins and near misses add into bonus. Adding a fraction of a point sixty times a second would drift.

The order inside a tick

examples/lane-dodger/src/sim/sim.zigstepPlaying
    fn stepPlaying(w: *World, input: Input) void {
        const dt = config.tick_dt;

        w.steer(input);
        w.movePlayer(dt);

        const moved = w.speed() * dt;
        w.advanceEntities(moved);
        w.spawnIfDue(moved);
        w.collide();

        w.distance += moved;
        w.time += dt;
        w.score = w.bonus + @as(u32, @intFromFloat(w.distance * config.points_per_unit));
        if (w.score > w.best) w.best = w.score;
    }

Written out, a tick does this:

  1. Move the target lane if a press arrived.
  2. Slide x towards that lane at constant speed.
  3. Move every entity down by speed * dt.
  4. Score a near miss the first time a block reaches player height.
  5. Destroy anything below the field. A missed coin resets the combo.
  6. Spawn a row if the gap has been travelled.
  7. Test the player box against every entity box.
  8. Recompute the score as bonus plus distance.

The order is not arbitrary. The player moves first, then the world moves, then they are tested against each other. Testing collisions before moving the world would let a block pass through the player on the tick it arrives.

steer reads the input and moves the target lane by one.

examples/lane-dodger/src/sim/sim.zigsteer
    fn steer(w: *World, input: Input) void {
        const from = w.player.lane;
        var lane: i32 = from;
        if (input.left) lane -= 1;
        if (input.right) lane += 1;
        const clamped: u8 = @intCast(std.math.clamp(lane, 0, config.lane_count - 1));
        if (clamped != from) {
            w.player.lane = clamped;
            w.events.push(.{ .lane_changed = .{ .from = from, .to = clamped } });
        }
    }

One press moves one lane, and the clamp keeps the player on the board. Pressing twice quickly sets a target two lanes away, and movePlayer slides there continuously.

Collision

examples/lane-dodger/src/sim/sim.zigcollide
    fn collide(w: *World) void {
        var it = w.entities.iterator();
        while (it.next()) |entry| {
            const e = entry.value;
            const ex = config.laneCenter(e.lane);
            switch (e.kind) {
                .block => {
                    if (overlaps(
                        w.player.x,
                        config.player_y,
                        config.player_half_w,
                        config.player_half_h,
                        ex,
                        e.y,
                        config.block_half_w,
                        config.block_half_h,
                    )) {
                        w.phase = .dead;
                        w.death_time = 0;
                        w.combo = 0;
                        w.events.push(.{ .crashed = .{ .x = w.player.x, .y = config.player_y } });
                        return;
                    }
                },
                .coin => {
                    if (overlaps(
                        w.player.x,
                        config.player_y,
                        config.player_half_w,
                        config.player_half_h,
                        ex,
                        e.y,
                        config.coin_half,
                        config.coin_half,
                    )) {
                        if (w.combo < config.max_combo) w.combo += 1;
                        const points = config.coin_points * w.combo;
                        w.bonus += points;
                        w.coins += 1;
                        w.events.push(.{ .coin = .{
                            .x = ex,
                            .y = e.y,
                            .combo = w.combo,
                            .points = points,
                        } });
                        w.entities.destroy(entry.handle);
                    }
                },
            }
        }
    }

Both tests are axis-aligned box overlaps.

examples/lane-dodger/src/sim/sim.zigoverlaps
fn overlaps(

A block ends the run. A coin adds to the combo, pays out, and is destroyed. The combo is capped, and it resets when a coin falls off the bottom uncollected.

Randomness the game owns

The world carries its own generator.

examples/lane-dodger/src/sim/rng.zignext
pub fn next(self: *Rng) u32 {
    const old = self.state;
    self.state = old *% 6364136223846793005 +% self.inc;
    const xorshifted: u32 = @truncate(((old >> 18) ^ old) >> 27);
    const rot: u5 = @truncate(old >> 59);
    return std.math.rotr(u32, xorshifted, rot);
}

Thirty lines of PCG32 instead of a call into the standard library. The reason is reproducibility. A seed plus a list of inputs has to replay to the identical run, so the replay test can assert on exact scores and a bad run can be reported as a seed rather than a video. Standard library generators are free to change algorithm between releases, and this guide tracks Zig master.

Each run advances the stream rather than reusing the seed, so the second game of a session is not a replay of the first.