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.
- Pin the randomness. Replace
Math.randomwith a seeded generator and every run becomes identical. A bug that appears on run 40 of 100 becomes a bug you can reproduce on demand, which is most of the work of fixing it. - Pin the clock. Games that use
Date.nowfor timing behave differently depending on how fast the machine is. Feeding fixed time steps means the headless run is deterministic and much faster than real time — you can simulate several minutes of play in under a second. - Drive it with a bot. Once you control the frame loop, you can synthesise input. A crude bot that reads the level state and presses roughly the right key is enough to get deep into a game and find out whether anything explodes down there.
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.
→ Play our games · → The number that decides whether a track is fair