ALFTANET
← All devlog posts

Testing a game with no browser

A canvas game is the hardest kind of program to check automatically: the entire output is pixels. But most of the bugs that actually kill a run aren't visual at all — and you can find those without rendering anything.

While building Airshaft we hit a situation that comes up more often than you'd think: we needed to know whether the game ran, and the browser in front of us wasn't cooperating. Something in the environment wasn't compositing frames, so the canvas stayed blank whether the game was working perfectly or crashing on startup. Both look identical: a black rectangle.

Rather than fight it, we asked a different question. What does the game actually need from a browser?

Ninety lines of nothing

Less than you'd expect, as it turns out. A 2D canvas game touches a drawing context constantly — fillRect, drawImage, save, restore, beginPath — but it almost never reads anything back. The game tells the canvas what to draw and moves on.

Which means you can replace the entire canvas with an object whose methods do nothing. Stub out the context API, provide a document with just enough of getElementById and addEventListener to satisfy the setup code, hand it a fake requestAnimationFrame that you drive yourself, and the game will happily run its full simulation in Node with no display anywhere in the process.

Ours came to about ninety lines. All the physics, collision, spawning, scoring and state machine logic runs exactly as it does in a real browser. The only thing that doesn't happen is the drawing — which is the part we could already check with our eyes.

What it caught immediately

Two crashes in Airshaft that killed the game on frame two.

That number is worth dwelling on. Not frame 400, not "after a few minutes of play", not in some rare corner of a late level. The second frame — meaning the game was dead before a human could possibly have reacted to it, and meaning the black rectangle we'd been staring at was a genuine crash the whole time and not an environment quirk.

Both were in collision setup, and both had the same shape: code that assumed something existed before it had been created. On the first frame the objects hadn't moved yet, so the broken branch was never reached. On the second frame it was.

In a browser this would have shown up as a console error, if anyone had been looking at the console at the moment it happened. Under the shim it was a stack trace with a line number, which is a much better way to receive information.

Making the runs repeatable

Once the game runs headlessly, a few small additions make it genuinely useful rather than just a crash detector.

That last one is how we check that levels are completable at all, which for a platformer is the one thing you really don't want to discover from a player. Airshaft's caverns each got a bot run confirming there was a route through.

Seeing the art without a screen

The obvious limitation is that stubbing out the drawing means you can't check the drawing. For Redline we wanted to look at the generated tunnel art, so we went one step further: instead of a context whose methods do nothing, a context whose methods record what was asked for, then replay it into a real image buffer and write out a PNG.

It only needs to support the handful of operations the game actually uses — filled rectangles and a few transforms, in Redline's case, because the whole thing is drawn in fifteen flat 8-bit colours. The result is a picture of frame 900 of a run that never appeared on any screen, which you can open and look at like a screenshot.

That's how we checked the tunnel generator was producing terrain that looked right, and it's how we'd generate promotional shots for a game that's awkward to capture live.

The general point

The instinct with graphical games is that testing means either a full browser automation stack or nothing, and for a small project the first option is heavy enough that most people pick nothing.

But the split isn't visual/non-visual, it's does the game read anything back from the renderer. Almost none of them do. Which means the simulation — the part where all the interesting bugs live — can be lifted out and run anywhere, for the price of an afternoon and a file full of empty functions.

Two frame-two crashes in a game we were about to ship is a good return on ninety lines.