A Game You Can Test
Press Play. Arrow keys or A and D change lane. Space starts a run. M
mutes the sound.
LANE DODGER
Three lanes. Take the coins, they mark the gap.
Three lanes. Blocks come at you. Coins add to the score. The game keeps getting faster until you lose. A run lasts about half a minute.
Games are hard to test
The interesting behaviour sits inside a loop that reads the keyboard, talks to a GPU and runs sixty times a second. None of those three belongs in a test.
So most small games have no tests. They get tuned by playing them. The difficulty becomes whatever the person doing the tuning could handle that week.
This game has fifty-five tests, and none of them needs a window.
One rule makes that possible
The rules of the game live in one module. That module does not import raylib, a windowing library, or a clock. It never allocates. It turns a seed and a list of inputs into a list of states.
/// Edge-triggered: each field means "pressed during this tick", not "held".
/// A held key must not slide the player across the board.
pub const Input = struct {
left: bool = false,
right: bool = false,
confirm: bool = false,
pub const none: Input = .{};
};Everything else reads from that state. The renderer draws the world. The audio plays a note when something happens. Neither one can change the outcome of a run.
The networking chapters are built the same way, with
protocol code that parses from a Reader instead of a socket. Code that only
runs inside the whole program does not get tested.
What the tests actually do
A bot plays the course for four minutes of game time at every seed. It must never crash. If the generator can produce a row nobody could get through, this test finds it. It also found a bug in the bot: it was steering into blocks whose centres had passed the player, but whose bodies had not.
The same bot runs again with a delay in front of it. The delay stands in for human reaction time, and the test checks the average run length. A machine with no reaction time surviving a course says nothing about whether a person can play it. This is the test that caught a difficulty curve that stopped rising after sixty seconds. A good player never lost.
A third test runs two worlds from one seed and one script of inputs, and compares them on every tick.
Where the code is
src/
sim/ the rules. imports nothing.
render/ what it looks like. reads the world, never writes it.
audio/ what it sounds like. holds no raylib either.
platform/ raylib input and the audio device
main.zig window and the fixed timestep loop
The whole project is on GitHub under examples/lane-dodger. Every code block in these chapters carries the path it came from, and that path links to the file. The blocks are read off disk when this page is built, so a chapter cannot end up describing a function that was deleted months ago.
The chapters walk the whole thing, in the order you would build it.
The chapters
- Vendoring raylib: how to add raylib to a Zig project, and why the dependency here is its C rather than its build script.
- The world: the field, the player’s two positions, and the order of work inside a tick.
- The loop: a fixed timestep, and why
steptakes nodt. - Handles: a pool of entities that never allocates, referred to by handles rather than pointers.
- Drawing it: field units onto a window, the road, and a particle system with no raylib in it.
- Input: presses latched once a frame and handed to exactly one tick.
- Difficulty: row spacing derived from what is possible, not tuned until it feels right.
- Sound: five effects, no audio files.
- Build it from scratch: the order to write the files in, and the two points where you can stop and check.