Zero Day: a tiny first-person shooter, and how I tested it
Zero Day is the fourth game hiding on this site’s desktop. You are a security engineer on a server floor full of rogue processes. Find the keycard, open the locked door, reach the exit. It is a first-person shooter in the style of Wolfenstein 3D, drawn in software on a 320 by 200 canvas, and it is open from the Games folder on the desktop. The terminal knows the way.
I treated it as an excuse to build something that looks hard and is mostly arithmetic, and to see how far tests can go for a game you play with your eyes.
What the game is
There are two levels. The first, a server floor, is 24 cells wide and 15 deep, with five bots of two kinds. The second, a data centre of rack aisles, is 28 cells wide and 17 deep, with seven. A rogue process is slow and tough and hits hard up close. A glitch is quick and fragile and shoots from a distance, and only hits about half the time. You start with 100 health and 24 rounds. Ammo and health packs are on the floor, and so is the keycard that opens the one locked door between you and the exit on each level. Clearing the first level takes you straight into the second, with your health and ammo topped up to at least 50 and 12, and the clock and your kills carrying on. Your best time is for the whole run.
You hold a small sci-fi energy pistol at the bottom of the view. It is drawn in code like everything else: slim barrel with glowing coils, an energy cell down the middle whose segments light according to your ammo, and a dark glove round the grip. It kicks when it fires, sways as you walk and flashes at the muzzle (a flash limited by the same three-a-second rule as the screen flashes), and with Reduce animations on it simply sits still.
Controls are W A S D or the arrow keys, Q and E to strafe, Space to fire and P to pause. On a touch screen there is a pad with walk, strafe and turn buttons and a big Fire button. There is sound, with a mute switch (a button in the window, or M). There is no mouse look yet.
How a flat map becomes a 3D view
The level is a grid of text rows. For every one of the 320 columns of the screen, the renderer shoots a ray from the player into the map and walks it from cell to cell until it enters a wall. This is called DDA, and it is the whole trick. The distance to the wall decides how tall to draw that column: near walls are tall, far walls are short.
Two details are easy to get wrong. The first is the fisheye effect. If you use the true length of each ray, a flat wall bulges at the edges of the screen, because the rays at the edges are slanted and longer. The fix is to use the distance straight ahead, which is the ray’s length times the cosine of its angle from the view direction. A test checks that a flat wall seen head-on is the same height at the left edge, the middle and the right edge.
The second is which way a texture runs along a wall. Looking at a wall from the east, the texture’s left-to-right direction is the opposite of looking from the west, and the same goes for north and south. Get one wrong and a texture is mirrored. A test checks the texture column on all four faces.
After the walls, sprites (bots, pickups and the exit) are drawn from far to near. A depth value per column stops a sprite from showing through a wall, and a test checks that a sprite behind a closed door is invisible, and that only the edge of a sprite sticking out from behind a pillar is drawn.
Sound, with nothing to download
Every sound is built when it is played, by the browser’s Web Audio, from a few oscillators and filtered noise: a downward zap for the pistol, a thump when you are hit, rising notes for a keycard, a rumble and a beep for the door, short fanfares for clearing a level and winning, a long fall when you die. The recipes are plain data (pitch, glide, length, loudness), so tests can check them: every pitch is audible, every sound is short and none is loud, and the common ones (shots, hits) have tails under 0.2 seconds so a firefight does not smear into noise.
Browsers do not let a page make sound until you have interacted with it, so the audio is only built on your first key press or touch in the window. A step of the game can start at most three sounds, always the most important news, and at most twelve voices sound at once. Muting cuts whatever is still sounding at once, is remembered in your browser, and only the exact saved text for “muted” counts, so a damaged setting means sound on. Nothing important is only a sound: everything that makes a noise also shows on screen and is announced in words.
No images at all
Every wall, the door and every sprite is generated by code from a few lines of arithmetic and a deterministic bit of noise, so there are no image files to download, and the site’s strict content security policy has nothing to allow. The whole game, two levels and sound included, is about 12 KB compressed, loaded only when someone opens it, and a test fails the build if it grows past 16 KB.
Rules without a screen
As with the other games, the rules know nothing about the page. The player, the bots, shooting, damage, pickups, the door, winning and losing live in plain TypeScript that takes time in as seconds and randomness from a seeded generator. That makes a fight repeatable: the same seed gives the same fight, and a test checks it.
The bots are a small state machine: idle, chase, attack, hurt and dead. They wake when they can see you, a hit always wakes one, and an attacking bot winds up for half a second before its first blow, so you get a moment to react. They cannot pass through walls, through each other or through you. In sight, they walk straight at you. Out of sight, they follow the shortest route through the level, which goes round walls and through open doors but never through a shut one, because bots cannot open doors. The route is a breadth-first count of steps from the player’s cell, rebuilt only when the player changes cell or a door opens.
Testing a game you play by looking
The plan was to make every rule something a test could fail on.
Geometry has hand-computed answers. From a known spot, a ray at a known angle hits a wall a known distance away. Tests check straight on, at 45 degrees, at a shallow angle, exactly into a room corner and onto a pillar’s corner. Another sends the player through a one-cell wall at 16 headings and speeds up to 100 seconds in a single step, and checks they never end up inside it.
Every level must be winnable. For each level, a test plans a route over the grid from the start to the keycard to the exit, and a bot walks it. With the bots switched off it always wins. With the bots on, a bot that also shoots the nearest bot it can see wins on six different seeds, and a bot that ignores them takes real damage. So each level is beatable and not trivial, and a bot that fights its way through one level and then the other, carrying over what the game carries, wins the whole run.
Whole games run in a real browser. Playing a game in real time makes for flaky tests, so these tests fake the page’s clock and fix the random seed. Game time only moves when the test says so, and one run is the same as the next. One test has a bot steer through the real keyboard, using only what the game says in words for screen readers (position, heading, health, ammo and the nearest bot in sight, with how many degrees to turn to face it): it shoots what it can see, picks up the keycard, walks through the door and reaches the exit on the first level and then again on the second, and the test checks that the first exit goes straight on instead of ending the game, then the win screen, the announcement and the saved best run. Another stands next to a bot until it loses.
Breaking things on purpose. For the riskiest rules I broke the code and checked that a test noticed: no wall collision, line of sight that always says yes, no damage, shots that pass through walls, no fire-rate limit, infinite ammo, bots that walk through each other, a door that opens without the key, and a flash rate limit that is too short. Every one made at least one test fail.
Bugs the tests found in my own code
A quick tap was lost. The game reads which keys are held once per step, about every 16 milliseconds. A test that pressed Space, which is a key-down and a key-up in the same instant, fired nothing, because by the next step the key was already up. A real tap is slower, but it can still fall between two steps. Now a key released before any step has seen it is reported once, and a pause or window switch clears it, so it cannot fire later by surprise.
A test timed out for the wrong reason. One test asserted that the player walked across a room, and failed at exactly the same spot every time. The cause was not the game. The assertion helper I used gives up after one second by default, and the walk takes about two.
A button did nothing. The Fire button was wired to the shell’s click handling, which only knew about keys that act when pressed, and not the held ones. It did nothing until a test clicked it.
Made to be playable by more people
- Touch: every button is at least 40 pixels, the pad and the view stop the browser scrolling or zooming, and the layouts are checked on a phone, on the narrowest 320 pixel phone and in landscape, so the controls always fit on the screen.
- Screen readers: the canvas label reports position, heading, health, ammo, bots left, and the nearest bot in sight with how many degrees to turn to face it. Spoken messages are kept short: health is announced once at 50, 25 and 10, ammo once when it runs low and once when it is gone, and routine news such as a pickup is held back if something was just said. The keycard and the door always get through.
- Flashing and motion: a hit or a shot can flash the screen, but never more than three times a second, never very bright, and not at all when Settings has “Reduce animations” on or the system asks for less motion. Screen shake follows the same rule.
- A way out: a note in the window points to Log Detective, which is text-based and works well with a screen reader.
- Pausing hides the view, after the loophole I wrote about in the Log Detective post: a paused game that you can still see is a game you can study with the clock stopped.
Fast enough, and checked
A test renders frames of the level with every sprite in view and fails if the average goes over 4 milliseconds. It is about a millisecond on my machine, and a frame has 16. The game step has a 1 millisecond budget. These limits are loose enough not to flake on a slow machine, and tight enough to catch something like allocating memory for every pixel.
Limits
It is two short levels with two kinds of bot, and the scripted bot clears both in about a minute of game time. The bots find their way round walls, but they only wake when they see you, and a ranged bot that stops in a doorway to shoot can plug it so the ones behind have to wait. I have tested the touch controls on emulated phones, not a real one, so how they feel is an open question. The test bot aims perfectly, which no person does, so the game is harder than the test suggests.