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

Difficulty You Can Prove

The course generator lays down rows of blocks. The rows have to get harder and stay possible.

Getting that wrong does not look like a bug from the outside. Every row still leaves a lane open. The game still runs. Nothing fails. It has just become unwinnable, and the only report is that people stop playing.

There is a floor under the spacing

Between one row arriving and the next, the player may have to cross the whole board. That costs two lane changes. The player also cannot start while the current row is level with them, because moving sideways into a block beside you is a crash.

The tightest spacing that is still winnable is the sum of those two.

examples/lane-dodger/src/sim/sim.zigfairnessFloor
/// The tightest row spacing that is still winnable at this speed: enough time
/// to cross the whole board, plus the time the current row spends sitting on
/// top of the player. Below this the game is impossible, however open the rows
/// look.
pub fn fairnessFloor(speed: f32) f32 {
    return 2 * config.lane_change_time + blockedSeconds(speed);
}

The second term shrinks as the game speeds up, because a faster row spends less time beside you. The first term does not change with speed at all.

Derive the spacing, do not tune it

The first version had two hand-picked constants: an easy spacing and a hard one. The game interpolated between them. It worked, but it left three numbers that had to agree with each other, with nothing anywhere saying so. Change the lane change time and the hard spacing quietly becomes unreachable.

Now the spacing is computed from the floor and decays towards it.

examples/lane-dodger/src/sim/sim.zigrowGapSeconds
/// Seconds between rows, decaying towards the floor without ever reaching it.
///
/// Difficulty therefore rises for as long as anyone keeps playing, and the
/// margin above unwinnable is a named constant rather than an accident of two
/// hand-tuned endpoints agreeing with a third.
pub fn rowGapSeconds(speed: f32, time: f32) f32 {
    const floor = fairnessFloor(speed) + config.gap_safety;
    const decay = std.math.exp(-time / config.gap_tau);
    if (config.row_gap_easy <= floor) return floor;
    return floor + (config.row_gap_easy - floor) * decay;
}

The decay never arrives, which fixes a second problem. The earlier curve reached its hardest setting at sixty seconds and then held there. A good enough player never lost at all.

The margin above unwinnable is now one named constant.

examples/lane-dodger/src/sim/config.ziggap_safety
/// How much slack the row spacing keeps above the point where the game stops
/// being winnable.
///
/// The spacing is not a hand-picked pair of numbers. There is a hard floor
/// under it, set by physics rather than taste: between one row arriving and the
/// next, the player must be able to cross the whole board (`2 *
/// lane_change_time`), and they cannot begin while the current row is level
/// with them (`2 * (player_half_h + block_half_h) / speed`). Spacing rows any
/// tighter than that sum makes the game unwinnable, and it would fail silently,
/// because every row would still leave a lane open, just not one anybody could
/// reach.
///
/// So `sim.rowGapSeconds` computes that floor, adds this margin, and decays
/// towards it. The game therefore gets harder forever and stays solvable
/// forever, and the constant a designer is free to move is the one that says
/// how much room to leave rather than the one that decides whether the game
/// works.
pub const gap_safety: f32 = 0.06;

Prove it by playing it

All of that is still an argument about four constants. Arguments about constants are wrong more often than anyone expects, so a program plays the game instead.

examples/lane-dodger/src/sim/bot.zigtargetLane
/// The lane the bot wants to be in.
pub fn targetLane(w: *const sim.World) u8 {
    const current = w.player.lane;
    const pos = position(w);
    const view = look(w);

    var best: ?u8 = null;
    var best_cost: f32 = std.math.floatMax(f32);

    for (0..config.lane_count) |i| {
        const lane: u8 = @intCast(i);
        const lane_f: f32 = @floatFromInt(i);

        if (view.blocked_now[lane]) continue;
        if (view.next) |next| {
            if (next.blocked[lane]) continue;
        }
        if (!pathClear(pos, lane_f, view.blocked_now)) continue;

        const distance = @abs(lane_f - pos);
        const travel = distance * config.lane_change_time;
        if (view.next) |next| {
            if (travel + margin > next.seconds) continue;
        }

        var cost = distance;
        // Prefer somewhere that is still good one row later, so the bot is not
        // forever solving the problem it just created.
        if (view.after) |after| {
            if (after.blocked[lane]) cost += 1.5;
        }
        // Take a coin when it is on the way and there is comfortable time.
        // Greed is capped: a missed dodge costs the run, a missed coin costs a
        // multiplier.
        if (view.next) |next| {
            if (next.coin_lane == lane and next.seconds > travel * 2) cost -= 0.5;
        }
        if (cost < best_cost) {
            best_cost = cost;
            best = lane;
        }
    }

    // Nothing safe is reachable. The spacing rule in config.zig is supposed to
    // make this impossible and the tests assert as much, but a policy that
    // panics here would be a policy that turns a tuning mistake into a crash.
    return best orelse current;
}

The policy looks at the next two rows. It picks a lane open in the first row, refuses a crossing it cannot finish in time, and refuses a path that would slide through a block currently level with it.

Then a test runs it for four minutes of game time at every seed.

examples/lane-dodger/src/sim/tests.zigsolvable course: the bot survives a long run at every seed
test "solvable course: the bot survives a long run at every seed" {
    // The real proof that the generator is fair. Four minutes is well past the
    // point where the difficulty ramp saturates, so this covers the hardest
    // spacing the game ever produces.
    for (0..24) |seed| {
        const w = playWithBot(seed, 240);
        std.testing.expectEqual(sim.Phase.playing, w.phase) catch |err| {
            std.debug.print(
                "seed {d}: crashed after {d:.1}s at score {d}\n",
                .{ seed, w.time, w.score },
            );
            return err;
        };
    }
}

The test failed the first time it ran, at seed 21, after 8.2 seconds of play.

What it found

The bot was steering into blocks it believed it had passed. It discarded anything whose centre was behind the player’s centre, measured with the block’s own half height. The right number is the sum of both half heights. A block whose centre is 41 units behind you still overlaps you when both bodies are 26 units tall.

examples/lane-dodger/src/sim/bot.zigreach
/// Vertical half-span within which a block and the player overlap. A block is
/// dangerous from `+reach` ahead of the player until `-reach` behind: using
/// only the block's own half-height here is the classic off-by-one-body bug.
const reach = config.player_half_h + config.block_half_h;

The regression test states the case in its title:

examples/lane-dodger/src/sim/bot.zigthe bot will not slide into a block that is still level with it
test "the bot will not slide into a block that is still level with it" {
    // The regression that the fairness test caught: a block whose centre is
    // behind the player's centre can still be overlapping it. Treating it as
    // passed makes the bot steer straight into its side.
    var w: sim.World = .init(1);
    w.start();
    w.entities.clear();
    const beside: u8 = w.player.lane + 1;
    _ = w.entities.create(.{
        .kind = .block,
        .lane = beside,
        // Behind the player's centre, but well inside the overlap band.
        .y = config.player_y + reach * 0.8,
        .y_prev = config.player_y + reach * 0.8,
    });
    try std.testing.expect(targetLane(&w) != beside);
}

Every unit test passed while this bug existed. The collision code was correct on its own, and so were the generator and the spacing. What was wrong was the reasoning that joined them together, and the only thing that exercises that is a run.

Solvable is not the same as playable

A bot with no reaction time surviving a course proves the course is solvable. It says nothing about whether a person can play it.

So the same policy runs again with a delay in front of it.

examples/lane-dodger/src/sim/tests.zigHuman
/// A player who sees the threat, then takes a moment to act on it, modelled as
/// pure latency: the decision made from the world as it looked `delay` ticks
/// ago is the one acted on now.
///
/// This is the only way the tests can say anything about whether the game is
/// *fun*. The bot proves the course is solvable, but it is solvable by
/// something with no reaction time at all, and a course only a machine can run
/// is not a game. Putting a delay in front of the same policy turns "is this
/// fair" into "is this fair to a person".
const Human = struct {
    delay: usize,
    buffer: [64]u8 = @splat(config.lane_count / 2),
    index: usize = 0,

    fn init(reaction_seconds: f32) Human {
        return .{ .delay = @intFromFloat(reaction_seconds * config.tick_hz) };
    }

    fn play(self: *Human, w: *const World) Input {
        self.buffer[self.index % self.buffer.len] = bot.targetLane(w);
        const decided = self.buffer[(self.index + self.buffer.len - self.delay) % self.buffer.len];
        self.index += 1;
        if (decided < w.player.lane) return .{ .left = true };
        if (decided > w.player.lane) return .{ .right = true };
        return .none;
    }
};

It models pure latency. The decision made from the world as it looked some ticks ago is the one acted on now. An earlier version counted down a timer and reset it whenever the ideal lane changed. That livelocked whenever the policy was torn between two lanes. The model reported the game as far harder than it was, and it took a while to notice the model was at fault rather than the game.

The numbers it produces are the difficulty curve:

ReactionMean run
100 ms58 s
150 ms46 s
200 ms30 s
250 ms26 s
300 ms24 s

That gives half a minute for an unhurried player and a minute for a sharp one. Everybody loses eventually, which is the part that took the most work to get right.

The test asserts the shape rather than the values, so the numbers can be tuned without rewriting it.

examples/lane-dodger/src/sim/tests.ziga run lasts about as long as a hyper casual run should
test "a run lasts about as long as a hyper casual run should" {
    // A quarter second is an unhurried player. They should get a real go at it,
    // not four seconds and a game over screen.
    const casual = meanSurvival(0.25, 24, 300);
    std.testing.expect(casual > 12 and casual < 70) catch |err| {
        std.debug.print("casual player averaged {d:.1}s\n", .{casual});
        return err;
    };

    // A sharp player must last longer, or there is no skill in it.
    const sharp = meanSurvival(0.10, 24, 300);
    std.testing.expect(sharp > casual * 1.5) catch |err| {
        std.debug.print("sharp {d:.1}s vs casual {d:.1}s\n", .{ sharp, casual });
        return err;
    };

    // And must still lose. This is the test that would have caught the
    // saturating difficulty curve, where a sharp player simply never died.
    std.testing.expect(sharp < 180) catch |err| {
        std.debug.print("sharp player averaged {d:.1}s: the game stops getting harder\n", .{sharp});
        return err;
    };
}

The last assertion caught the flat difficulty curve. A sharp player was averaging the full three hundred second cap, and every other test was green.

Two smaller things

The opening is gentle. Inside a grace window, no row blocks more than one lane. A single nudge answers everything the game asks in the first few seconds.

This one is a judgement call rather than a measurement. The reaction time model already knows the controls perfectly, so it cannot show what a player still hunting for the keys runs into. Turning the window off moves the modelled 200 ms average by three tenths of a second.

examples/lane-dodger/src/sim/config.ziggrace_seconds
/// Opening seconds during which no row blocks more than one lane.
///
/// Measured, not guessed. Modelling a player with a 200 ms reaction showed a
/// mean survival of 29 s but a worst seed of 3.9 s: some openings happened to
/// demand a two lane crossing before anyone had settled in, and a hyper casual
/// game that can kill you in four seconds on your first go does not get a
/// second one. Inside the grace window every row leaves two lanes open, so a
/// single sideways nudge always answers it.
pub const grace_seconds: f32 = 7;

Coins mark the safe lane. A coin only ever goes in a lane its own row leaves open. When a row blocks two of three lanes, the coin sits in the one gap. A player chasing coins is being steered through the course without being told anything. It is four lines in the spawner, and it is the only tutorial the game has.

examples/lane-dodger/src/sim/sim.zigspawnRow
    fn spawnRow(w: *World) void {
        const d = w.difficulty();

        // Shuffle the lanes, then block a prefix of them. Blocking a prefix of
        // a shuffle is what guarantees the blocked lanes are distinct, and the
        // count is capped below `lane_count`, so a free lane always remains.
        var lanes: [config.lane_count]u8 = undefined;
        for (&lanes, 0..) |*lane, i| lane.* = @intCast(i);
        var i: usize = lanes.len;
        while (i > 1) {
            i -= 1;
            const j = w.rng.below(@intCast(i + 1));
            std.mem.swap(u8, &lanes[i], &lanes[j]);
        }

        const two = w.time > config.grace_seconds and w.rng.chance(lerp(
            config.two_block_chance_easy,
            config.two_block_chance_hard,
            d,
        ));
        const blocked: usize = if (two) 2 else 1;
        std.debug.assert(blocked <= config.max_blocked_lanes);

        for (lanes[0..blocked]) |lane| {
            _ = w.entities.create(.{
                .kind = .block,
                .lane = lane,
                .y = config.spawn_y,
                .y_prev = config.spawn_y,
            });
        }

        // The coin goes in a lane this row leaves open. That is not only
        // fairness bookkeeping: when a row blocks two of three lanes, the coin
        // is sitting in the one gap, so following the coins is the same thing
        // as playing correctly. The game teaches itself.
        const free = lanes[blocked..];
        if (free.len > 0 and w.rng.chance(config.coin_chance)) {
            const lane = free[w.rng.below(@intCast(free.len))];
            _ = w.entities.create(.{
                .kind = .coin,
                .lane = lane,
                .y = config.spawn_y,
                .y_prev = config.spawn_y,
            });
        }
    }