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 show up as a bug. Every row still leaves a lane open and the game still runs, but nobody can survive the course. The only sign 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.
/// 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.
/// 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.
/// How much slack the row spacing keeps above the point where the game stops
/// being winnable.
///
/// The spacing has a hard floor under it, set by the game's physics: 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 gets harder forever and stays solvable forever. This
/// margin is the constant a designer tunes, and it only sets how much room to
/// leave. The floor, which decides whether the game can be won, is computed.
pub const gap_safety: f32 = 0.06;Prove it by playing it
All of that is still reasoning about four constants. That kind of reasoning is easy to get wrong, so a program plays the game instead.
/// 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.
// The bot limits how hard it chases coins: a missed dodge ends the run,
// and a missed coin only 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 check that. But if the policy
// panicked here, a tuning mistake would crash the program.
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.
test "solvable course: the bot survives a long run at every seed" {
// This test proves 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 each body is 52 units tall.
/// 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 off-by-one-body bug that the
/// fairness test found.
const reach = config.player_half_h + config.block_half_h;The regression test states the case in its title:
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. Only a full run of the game tests that reasoning.
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.
/// 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.
///
/// With this model the tests can say something about whether the game is
/// *fun*. The bot proves the course is solvable, but the bot has no reaction
/// time at all, and a course only a machine can run is not a game. With a
/// delay in front of the same policy, the test asks whether the game is 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:
| Reaction | Mean run |
|---|---|
| 100 ms | 58 s |
| 150 ms | 46 s |
| 200 ms | 30 s |
| 250 ms | 26 s |
| 300 ms | 24 s |
That gives half a minute for an unhurried player and a minute for a sharp one. Everybody loses eventually. That part 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.
test "a run lasts about as long as a hyper casual run should" {
// A quarter second is a slow, relaxed player. They should get a proper run,
// not a game over screen after a few seconds.
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 skill makes no difference.
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;
};
// A sharp player must still lose. This check would have caught the
// saturating difficulty curve, where a sharp player 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.
/// Opening seconds during which no row blocks more than one lane.
///
/// This is a judgement call, not a measurement. The reaction time model in the
/// tests already knows the controls, so it cannot show a first-time player
/// who is still looking for the keys. Turning the window off moves the
/// modelled 200 ms average from 29.5 s to 29.2 s. Inside the grace window
/// every row leaves two lanes open, so one lane change is always enough to
/// pass 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 who chases coins follows the safe path without being told. The rule is four statements in the spawner. The game has no other tutorial.
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. This keeps the row
// fair, and it also teaches the player: 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.
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,
});
}
}