Audamir

part 13

How to test a game we will never stop changing

More than twelve hundred tests, mutants that must be catchable, and a bug that was found by a player, not by a test. What the testing of a game really guards and where it fails.

· 13 min read

In 1993, before the game Doom was uploaded to the server, it was tested, by recollection, for 30 hours in a row. Today we have 1,234 tests for Audamir that pass in one to three minutes. The right feeling about such a number is distrust rather than calm: if there are many tests, why is a bug still found?

This article is about what we found out in testing the game. The number of tests is not important. What is important is which kinds of protection hold the game together, and where it turned out that green is not proof.

This is how the number of tests grew by day from 17 October:

Growth of the number of tests from 17 Oct to 6 Nov 2026Number of calls of test() in the test files at the end of each day: 17 Oct 360, 18 Oct 727, 19 Oct 1,227, 20 Oct 1,373, 21 Oct 1,610, 22 Oct 1,948, 23 Oct 2,060, 24 Oct 2,157, 25 Oct 2,157, 26 Oct 2,218, 27 Oct 2,379, 28 Oct 2,446, 29 Oct 2,569, 30 Oct 2,963, 31 Oct 3,326, 1 Nov 3,575, 2 Nov 3,777, 3 Nov 3,953, 4 Nov 4,103, 5 Nov 4,256, 6 Nov 4,256. At the time of the article, 20 Oct, there were 1,234 tests according to the command node --test; on 6 Nov there were 4,564 according to the whole verification of the game.01,0002,0003,0004,0005,00017 Oct20 Oct23 Oct26 Oct29 Oct1 Nov4 Nov20 Oct: 1,234 teststime of the article6 Nov: 4,564 teststest() calls: 4,256a column = the number of test() calls at the end of the day (approximate)
The growth of the number of tests in the game from 17 Oct to 6 Nov 2026. A column is the number of calls of test( in the test files at the last record of the day, so an approximate number: some of the tests are created in loops, so in the tree of 6 Nov there are 4,256 calls and the whole run of node tools/verify.js counted 4,564 tests. The git history of the game starts on 18 Oct; the column of 17 Oct is from the saved copy of the project of 22:57, and nothing was recorded on 25 Oct. At the time of the article (20 Oct) there were 1,234 tests according to node --test.

What guards the game

The protection of the game was assembled gradually, always after it became clear what was missing. Today it can be summed up in six layers:

  1. Logic without a screen. The rules, the AI, the puzzles, the campaign and the economy are pure modules without the DOM (the same code runs in the browser, in a Worker and in Node). They can be tested quickly and deterministically.
  2. Screens against a fake DOM. The real scripts of the user interface run in Node against tests/fake-dom.js. Fast, but the fake DOM does not know CSS.
  3. Fingerprints. A golden fingerprint of a map from a fixed seed, a fingerprint of 172 ordinary games (baseline), 18 check measurements (calibrate --verify), game records that must replay the same way.
  4. Measurements. Every tool has --check: missions, minigames, puzzles, the economy of the dice, achievements. They say whether the game has changed in difficulty, not only in correctness.
  5. A smoke test of the finished product. A real browser (headless Chromium) plays the real build with real clicks and keys.
  6. Independent review. After every larger phase the code is read by another agent, which changes nothing and only reports.

The same six layers in one picture, each with one number from the game of 6 November (the text has the numbers of 20 October):

Six layers of protection of the gameA scheme of six cards. 1. Logic without a screen: the rules, the AI, the puzzles and the economy are pure modules, the tests are fast and always the same. 2. Screens on a fake page: the real scripts of the interface run against a false page that does not know the look. Both layers live in one suite of 4,564 tests, which runs for 23 minutes 12 seconds. 3. Fingerprints: fixed games are played and compared with a stored fingerprint, there are 196 of them. 4. Measurements: the tools check that the game has not changed in difficulty, there are more than 35 such tools. 5. Smoke test of the game: a real browser plays the finished build with real clicks, the check of the build takes 40 seconds. 6. Independent review: another agent reads the code and only reports, in the campaign it found 7 real bugs. Without the fifth and the sixth layer the first four would only claim what we expected of them.1Logic without a screenThe rules, the AI, the puzzles andthe economy are pure modules withno screen: fast, always the same.2Screens on a fake pageThe real scripts of the interfacerun against a fake page. Fast, butit does not know the look (CSS).3FingerprintsFixed games are played andcompared with a stored fingerprint,so every change gives itself away.4MeasurementsThe tools check that the game hasnot changed in difficulty, not onlyin correctness.5Smoke test of the gameA real browser plays the finishedbuild with real clicks and keys.6Independent reviewAfter a larger phase another agentreads the code, changes nothingand only reports.4,564tests in the suite23 min 12 ssame suiteas in layer 1196fingerprintsof ordinary games35+toolswith a check40 scheckof the build7campaign bugsfrom one reviewWithout the fifth and sixth layer the first fourwould only claim what we expected of them.
Six layers of protection and one real number from each. The numbers are from the game of 6 Nov 2026 (the tree of the game ce303e2, one whole run of node tools/verify.js: 32 min 5 s, all phases passed), while the text has the numbers of 20 Oct. The first and the second layer share a number, because both live in one suite of tests. The number at the fourth layer is the number of tools of the game that read the option --check (at least 36, hence '35+'), the number at the sixth is from the text of the article.

Of these, the fifth and the sixth layers are the interesting ones, because without them the first four would only claim what we expected of them.

Mutations: testing the tests

A test that never fails need not be a good one. It may merely be blind. That is why a mutation check is done after every phase: errors are deliberately inserted into a copy of the code (one change at a time: an inverted condition, a different limit, a skipped step) and the suite of tests is run. If the change does not make it fail, a test is missing.

One mutation looks like this (a real line of the game and a real test from today's state):

What a mutation is: one change in a copy of the code and two possible endsA real line of the game that leaves the text unchanged if it is shorter than or equal to the limit: if (s.length <= max) return s; In a copy of the code a single character is changed and a mutant is made: if (s.length < max) return s; The suite of tests is run. The first possible end, the real one in this case: a test fails, the mutant is caught, the test really guards this boundary. The second possible end: all the tests pass, the mutant survived, so a test is missing or the code is useless.The original line of the gamejs/core/feedback.jsif (s.length <= max) return s;one change in a copy of the codeMutantif (s.length < max) return s;the suite of tests is runA test failsThe mutant is caught: the testreally guards this boundary.the real end of this exampleEverything passesThe mutant survived: a test ismissing or the code is useless.the other possible end
One mutation on a real line of the game (js/core/feedback.js, the function that cuts the text of the player to the limit; dev, version 0.119.0). The change of <= to < was run on 6 Nov 2026 on a copy of the file with the real test tests/core/feedback.test.js: the original code passes all 39 tests, with the mutant one fails. It is a mutation of the same kind as in the check of 20 Oct, not one of its 126 (those were not preserved).

Real numbers from this project:

  • The window "Report a bug or idea": 126 mutations (every limit, the cleaning of the text, the addresses in the errors, links, the fallback ways of copying, focus, Esc and Tab). After the first rounds the tests did not catch 8 of them. Every surviving mutation led to a new test. In the end all were caught.
  • The territory generator: 37 mutants, 35 caught, three of them only after the tests were extended with a golden fingerprint. Two survived because the code was redundant. It was removed from the project.
  • According to the records from the development (these counts are not in the documentation of the project): the Iron Run approximately 55 mutants, Puzzle Labs approximately 66, the Collection 27 and 88, the Threat ladder around 175. Everything was caught after tests were added, except for one equivalent mutant.

The first two checks together with today's third (the renaming of the companions in the campaign, 6 November) in one picture:

How many errors were caught when the tests were testedThree checks by means of mutations, that is deliberate errors inserted into a copy of the code. The window "Report a bug or idea": 126 mutations, in the first rounds the tests caught 118 and 8 survived, after the tests were added all 126. The territory generator: 37 mutants, in the first round 32 caught and 5 survived, after the fix 35 caught and 2 survived, because it was redundant code, which was removed. The renaming of the companions in the campaign (6 Nov 2026): 27 mutations, first 26 caught and 1 survived, after a test was added all 27. A surviving mutant is a missing test or useless code.The window “Report a bug or idea”126 mutationsfirst roundscaught: 118survived: 8after the fixcaught: 126survived: 0Every surviving mutation led to a new test.The territory generator37 mutantsfirst roundcaught: 32survived: 5after the fixcaught: 35survived: 2Three were caught after the tests were extendedwith a golden fingerprint; two survived becausethe code was redundant: it was removed.Renaming the companions (6 Nov)27 mutationsfirst roundcaught: 26survived: 1after the fixcaught: 27survived: 0One survived: the test did not count the thirdcompanion, so one was added.caught: the test failedsurvived: all the tests were silent
Three checks by means of mutations. The first two are from the text of the article (20 Oct 2026; docs/DESIGN.md §22 and §19.6), the third from 6 Nov 2026 (27 aimed mutations in the renaming of the companions in the campaign). The figure for the first round of the territory generator (32 caught, 5 survived) is computed from the sentence that of the 35 caught mutants three were caught only after the tests were extended. A surviving mutant is a missing test or useless code.

An important sentence that can be read from these numbers: a surviving mutant is either a missing test or useless code. It is worth knowing both.

The same principle is also applied to the smoke test of the finished build. It was verified that it fails when it should: 18 deliberately broken builds (a missing link, an Esc that does not close the window, a copy that claims success and copies nothing, links out in the build for portals, a missing service worker, a broken path to the Worker, an error in the console at start and others). The smoke test caught all 18, and each for a good reason. A fire alarm is not tested by looking at it, but by lighting a match.

Three traps we ran into

1. A test that never fails

The helper function waitFor waits until a condition is met. When the time runs out it **only returns false** and does not throw an exception. A test that calls it without assert.ok therefore passes even when the end card never appears. Only a mutation revealed this. The rule since then: the condition is always written as assert.ok(await waitFor(…)).

A similar trap is in Node itself. A cancelled test (the time limit expired) reports fail 0 and cancelled 1, but the process exits with a non-zero code. A mutation script that looked at the number of failures declared such mutants "survived". What decides is the return code of the process.

2. A test that brings down the machine

During a mutation check one test process grew to approximately 33 GB of memory and the machine froze. Twice, because after the first crash the same thing was run again before the cause was found.

The cause: a failing assert.equal(element, null), where element was an element of the fake DOM. On a failure Node prints and compares both operands, and the graph of a single element is the entire fake document. A test that was supposed to announce "this does not match" tried to print the whole world.

Three rules have applied since then:

  • **A DOM element is never passed to assert.equal.** Identity is compared (assert.ok(a === b, 'description')), and the header of fake-dom.js warns about it.
  • Heavy runs are started one process at a time and in small batches, with a memory limit (ulimit -v).
  • After a crash the cause is looked for first. The same thing is not run again.

The last rule is probably the most expensively acquired of the whole series and the most usable outside games.

3. A bug that a human found

The Puzzle Labs screen could not be scrolled, so part of the list stayed out of reach. In the CSS overflow-y: auto was missing. The tests could not find this bug, because the fake DOM does not know CSS. And the manual check missed it: the screenshot "list at the bottom" came out exactly like "at the top" and nobody noticed.

Three things came out of it:

  • a test layout.test.js that statically verifies that every screen except the game has overflow-y: auto,
  • one-off scripts that scroll with a real wheel and touch (via the DevTools protocol) and compare the position before and after (only the static test stayed in the repository),
  • a rule: the screenshots "before and after scrolling" must come out different; if they are the same, it is a bug, not a coincidence.

An independent review and a hundred tests can stay silent, and a person with a mouse finds it at once. That is why the verification of the finished build is also done with real input: a click, a wheel, a key and a touch on a mobile.

When test data lies: migration

The game stores the profile, the campaign, the backup and games in progress in the browser. When the IDs change (in this project Slovak identifiers were changed to English ones), it must be guaranteed that the old save still works and nothing is paid out twice.

A test with hand-written data passed. A real profile, however, revealed a bug: four Puzzle Labs achievements were paid out a second time after the labs were renamed, because the achievement ID is derived from the lab ID and was missing from the migration table. It was found out by creating, with the old code (from the last commit before the change, via git archive), a profile, a campaign, a backup and a saved game and loading them with the new code. An independent review found the same problem only after about twenty minutes; a comparison of two sets of IDs finds it in a second.

The lesson: test data written by hand reflects what the author wants, not what exists. If the format that a player already has saved changes, the data must be made by the old code. Whoever does not do this finds the bug only with the first user.

The same principle stands behind two tools (talents-migration.js, integration-check.js) that produce profiles at several stages from the archive of the old tree and verify that the new code handles them.

Determinism as a superpower

The best thing the game did for testing is not any test. It is that all the randomness of the game is derived from the state (seed, round, turn, order of the attack). That is why every game is reproducible:

  • tools/replay.js --games 300 --check plays 300 random games with the AI, records them and replays them. Every checkpoint and the result must match.
  • A saved game does not roll the dice again on return (a roll cannot be "repeated" by leaving to the home screen).
  • An error report carries the seed and the "salt" of the rolls, so the bug can be reproduced.

Time is another source of randomness. The tests of the puzzle of the day had the clock of the page pinned to an "easy day", because the difficulty grows during the week and the suite of tests would otherwise depend on which day it is run (this was verified by setting the clock to the hardest Sunday). In the tests with a Worker a two-minute timer stayed hanging, which kept the Node process alive.

Reviews by an agent: what they know and what they do not

After every larger phase (Puzzle Labs, the Collection, the Avatar[1] H1 to H4, the game record, achievements, the analysis) the code is read by another agent, which only reports. It is a cheap form of a second pair of eyes. According to the records from the development (not all the numbers are in the documentation): for the campaign the review found 7 real bugs, for the Collection 16 findings (7 medium and 9 low), for the achievements 12, for the analysis 13, and for Puzzle Labs no serious one, but a few real ones (the help drew the chances even in a puzzle, the end card could crash with a full list of results).

What the review did not find is just as instructive: it did not work out that the Puzzle Labs screen cannot be scrolled. On the other hand it found edge cases: two browser tabs overwriting one state, a "dead" run after the last attempt was discarded, a failure of copying. It is a good complement. It is not a replacement for a person who actually plays the game.

What I take from it

  1. Green is a claim, not proof. A test that has never failed must be broken to prove that it can fail.
  2. A surviving mutant is a gift. Either a test is missing or there is something unnecessary in the code.
  3. Tests made up out of one's head lie. If a saved format changes, create the data with the old code.
  4. Play with real input. Wheel, touch, key. Whoever does not scroll does not know whether it scrolls.
  5. After a crash do not run the same thing again. The cause first.
  6. Speed is a feature. A minute or two can be run after every change. An hour would be run only before handing in.

On the last point: this is what the one command that verifies the whole game today looks like:

One command to verify the whole gameA shortened real output of the command node tools/verify.js of 6 Nov 2026: layout of the files 0.4 s, the check corpus of drawn scenes 3.4 s (319 scenes agreed), the suite of tests 23 minutes 12 seconds (4,564 tests, 4,563 passed, 0 failed, 1 skipped), fingerprints of games 1.9 s (all 196 match), golden records 0.9 s, checks 4.2 s, migrations of old data 8 minutes 2 seconds, build of the game 40 s. In all 32 minutes 5 seconds, all phases passed.One command, one verification of the whole gamea shortened real output of 6 Nov 2026 (the output stays in English, as the game writes it)$ node tools/verify.jsverify: tree 499b279f1 (commit ce303e2)[0.4 s] layout: passed in 0.4 s ok board-prims.js (3.4 s) - 319 scenes drawn in 3.2 s: 319 equal[3.8 s] corpus: passed in 3.4 s ok node --test (23 min 12 s) - 4564 tests, 4563 pass, 0 fail, 1 skipped[23 min 15 s] suite: passed in 23 min 12 s ok baseline.js (1.9 s) - All 196 fingerprints match[23 min 17 s] baseline: passed in 1.9 s ok golden-records-ai2.json byte for byte - byte-identical[23 min 18 s] golden: passed in 0.9 s ok i18n.js --check (3.1 s) - de: 4162 of 4162 texts, 0 missing[23 min 22 s] checks: passed in 4.2 s ok slices-check (57.3 s) - identical: every call returned the same, …[31 min 24 s] migrations: passed in 8 min 02 s ok build.js --check (40.0 s) - build:check: everything is fine.[32 min 05 s] build: passed in 40.0 s=== all phases passed in 32 min 05 s: layout 0.4 s, corpus 3.4 s, suite 23 min 12 s, baseline 1.9 s, golden 0.9 s, checks 4.2 s, migrations 8 min 02 s, build 40.0 s
A shortened real output of the command node tools/verify.js, which verifies the whole game in one run: the tree of the game ce303e2 of 6 Nov 2026, 32 min 5 s, all phases passed. The descriptions of the phases are left out and long lines shortened with the character '…'; the output stays in English, as the game writes it.

And where the time goes in it:

Where the time goes in the verification of the whole gameA strip of the shares of time of one whole verification of the game, 32 minutes 5 seconds in all: the suite of tests 23 minutes 12 seconds (72%), migrations of old saved data 8 minutes 2 seconds (25%), the build of the game and a check of the finished build 40 seconds (2%), the other five phases together 10.8 seconds (less than 1%).Where the time goes (the whole verification: 32 min 5 s)suite of testsmigrationssuite of tests23 min 12 s · 72%migrations: old saved data, new code8 min 2 s · 25%build of the game and a check of the finished build40 s · 2%the other five phases together10.8 s · less than 1%
The shares of time from the same run: the suite of tests and the migrations of old saved data take the most.

Sources and notes

  • Counts: node --test in the main tree of the game of 20 Oct 2026 (1,234 tests, 1,233 passed, 1 skipped, 82 test files, --test-concurrency=3: 161 s). On a copy of the project from the previous morning (1,187 tests) the suite took 61 s with the default parallelism. The length of the tests approximately 29,000 lines.
  • Mutations and the smoke test of the build: docs/DESIGN.md §22 (126 mutations, 18 broken builds), §19.6 (37 mutants); the rest from the notes on the phases.
  • The memory accident: docs/DESIGN.md §22.3 ("A lesson about tests and memory") and an entry in the history (a commit of 18 Oct 2026, 23:24).
  • The pictures (indicative, the numbers are today's, not of 20 Oct): the numbers of the layers and the output are from one whole run of node tools/verify.js on the tree of the game ce303e2 of 6 Nov 2026 (dev shortly before version 0.119.0; 32 min 5 s, all phases passed). The growth of the number of tests is the number of calls of test( in the files tests/**/*.test.js at the last commit of each day of the branch dev (approximately; 17 Oct from the saved copy of the project). The mutations are from the text of the article and from the check of the renaming of the companions in the campaign (27 mutations, 6 Nov 2026). The line of code and the test in the sketch of the mutation are real (js/core/feedback.js and tests/core/feedback.test.js on dev 0.119.0, run on 6 Nov 2026).
  • Doom and 30 hours of testing: research for the opening article (Kushner, Masters of Doom, via Wikipedia); it is a recollection, not a measurement.

Notes

  1. Until version 0.80.1 of 1 November 2026 the Avatar was called Hero Dice (see From Hero Dice to Avatar (forthcoming article no. 39)). ↩︎