Development
npm run build # Compile TypeScript to out/
npm run start # Run the compiled game
npm run dev # Build and run in one step
npm run debug # Build and run in debug mode
npm run watch # Watch mode for development
npm test # Build, then run the test suite
npm run probe # Build, then run a measurement probe
npm run probe with no arguments lists what there is to measure, and which flags each probe takes. The probes that fly a shot take a grid and a build:
npm run probe -- seen-versus-kill
npm run probe -- column --grid 205x50 --build browser
npm run probe -- free-flight --passes 2
npm run probe -- versus-half --count 300
npm run probe -- overlay-anchor
npm run probe -- glyph-tuning
A flag the named probe does not read is refused rather than discarded. npm run probe -- overlay-anchor --grid 80x24 exits non-zero and says which flags that probe takes, because a figure quoted from a grid nothing read is a figure from a walk that never happened.
The table the rig checks a command line against lives in test/probes/probes.mjs, and test/probes.test.mjs reads it against the probes themselves: every probe takes exactly the flags listed for it, and the refusal is spawned end to end for its exit code. A probe that stops reading an argument fails the suite rather than going on accepting the flag in silence.
Reading a probe's argument list covers its contract with the rig and stops there, so every probe that calls the shared engagement is also run by the suite, on the smallest workload the rig can describe - one grid, one build, and the smallest --count and --passes it takes - with what it prints collected rather than printed. That is what reaches the calls themselves. The workload is named by its flags rather than by what they narrow, because --count is not one unit: it is the placement walk in column, frame-rate and seen-versus-kill, the frame count in free-flight and drop-clip, which reads --passes as measurements of the pair it times rather than as passes over a walk, and the seconds of game time a flight runs out to in versus-half. Neither flag narrows free-flight's own rate walk or its dark walk - --grid and --build are what reach those, cutting both from six build-and-grid pairs to one. A probe can pass the flag check and still be handing the engagement an argument list it no longer takes, which comes back as a dash in a table nothing is running; the figures a narrowed walk prints are worth nothing and none of them is read for its value, only for having arrived. overlay-anchor and glyph-tuning are left out, because neither imports an engagement to reach nor reads a flag to be narrowed by. The pair is declared in HARNESS_EXCLUSIONS rather than inferred, and the test compares the probes it actually left out against that list in both directions, so a third exclusion has to be written down and a probe that quietly stops importing the engagement fails instead of dropping out of the run unnoticed. The sentence above that names the pair is the third party to that comparison, read back out of this page by the same test, so the published copy cannot drift from the declared list either. It is found by its shape rather than by its position, so the paragraph can be rewritten around it, and both halves of that shape have to survive the rewrite: the two probe names each in a <code> span, and the words are left out immediately after them. Setting the names in plain text fails as surely as closing the sentence on different words - either one leaves the test reporting a page that states no such sentence, rather than reporting a published pair that disagrees with the declared list.
Line Endings
Everything in the repository is LF, pinned by .gitattributes rather than left to whichever editor writes a file last. Git normalizes on the way into the index, so an editor that writes CRLF still commits LF and a diff stays the size of the change rather than the size of the file. The file is tracked, so a clone needs nothing configured.
Adding a new dotfile is the one thing that can need a local step. A global excludes file of the .* kind will hide it, and how you find out depends on how you staged it: git add <file> names the path, so git refuses it out loud and points at -f, while a bulk git add . or git add -A passes over it without a word. The quiet case is the one to watch for - if a dotfile you thought you staged never appears in git status, check it with git check-ignore -v --no-index <file> and stage it with git add -f <file>.
The repository's standing answer to that is a ! line in .gitignore, where a repository rule outranks the global one. There is one for .gitattributes, one for .nojekyll, and one for .gitignore itself, each with a sentence beside it saying what going without would cost. The last of the three was missing until 0.15.1, and what it cost was every rule in the file: a bulk add had passed over it since the repository was started, so the file sat untracked and a clone ignored nothing, reading out/, node_modules/ and the verification agent's results folder as untracked there. test/repo-files.test.mjs reads those ! lines back and asks git whether the rules alone still rescue each path. It asks with --no-index, which is the part worth copying: plain git check-ignore consults the index first and reports nothing for a file git already tracks, so a negation deleted from under .gitattributes would read as fine for as long as the file stayed in the index, and would strand the next clone that had to add it again.
Tests
The suite uses the Node built-in test runner and adds no dependencies. It covers both builds, the agreement between them, the documentation site, the measurement rig, and the repository's own ignore rules. The site is checked as the text of its own files, which needs no browser: its menu, its palette, the geometry of the fixed bar in both layouts - the tallest child it holds against the height it declares, and whether an in-page anchor lands clear of it - the two halves of the layout breakpoint naming adjacent widths, whether a dropdown list is shown in each layout and each state, which side the last group anchors its panel to, and every page published from a repository file carrying all of that file's prose and list items. The resolver those checks read the stylesheet through is pinned in turn by test/page-style.test.mjs, which weighs every shape of selector CSS counts differently and refuses the four functional pseudo-classes it does not model - a selector carrying one, and a whole sheet carrying one anywhere in it, since every reader here matches a selector whole and so misses such a rule rather than mis-weighing it. What it cannot decide from text is a drawn width, which needs font metrics: a browser is still the only thing that measures whether the nav's row of entries fits the width it switches on at.
The terminal tests import the compiled output from out/, which is why npm test builds first. The browser build is a single self-contained index.html with no module boundary to import, so those tests read the file and evaluate its inline script. There are two ways in, and the difference between them is where the evaluation stops. loadBrowserEngine stops at the point the script starts touching the DOM: everything above that line is game logic and rendering, and an engine loaded that way needs nothing of a browser at all.
The half below that line is elements, pointers and the frame loop - which is why the logic above it was written there in the first place. Nothing in the suite ran it until 0.15.3, and three releases in that time carried a defect in it. So loadBrowserPage is the second way in: it evaluates the whole script against a stub DOM - a window, the six elements the page fetches by id, a navigator, a clock, and an animation frame queue the test pumps by hand - and hands back the page's live globals and the handles to drive them. test/browser-wiring.test.mjs is what drives it. That stub's getComputedStyle answers out of the page's own stylesheet rather than from a table beside it, because the overlay reads one resolved length back off the page - the size the stylesheet solved its label to, which is what the label floor is decided on. It folds top-level rules only: a declaration inside an @media or @supports block is left out rather than folded as though it were unconditional, the same way the resolver's own declarationsFor leaves one out, because nothing running in Node has a viewport or a colour scheme to test a prelude against. So give index.html its first at-rule over #fire or #boost and the stub reports that rule going unread, rather than a length a browser would only use under the query. Nothing below the marker needs a rasterizer: the one reading that decides anything is the advance of M, which fitGrid turns into the grid, and it is answered from Courier New's own 1229/2048 em rather than from a canvas. Pumping the loop by hand is also what makes it quick - a round flown out in a browser takes between thirty seconds and three minutes, because both pilots have to wreck in real time, and the same round on an advancing clock takes milliseconds.
Two readings do not survive that move and are not faked. The painted pixels are one: a colour index in the buffer is assertable and the paint over it is not, which is the same split glyph-tuning draws between its arithmetic and its rasterizer. Real pointer plumbing is the other: a stub proves the handler was called with a pointer id, which settles which key a press resolves to and is not evidence that pointer capture works.
So the source-text checks stay, and they are still the right tool for what they cover. A call site asserted as text is pinned as written and not as behaving, so each one names where the behaviour was confirmed instead, and each reports the index.html line it found rather than the region it searched. Some of what they hold cannot be reached any other way: the argument carrying the page's mute into a round opened from a ?versus link is always false, because nothing stores the setting across a load, so no run of the page can tell the argument from its absence - and the text check counts the openings instead, which makes a third way into a round a failure rather than a silently unmuted one.
The pulse cannon checks run at three grid sizes rather than one: the 80x24 a terminal opens at, the 60x20 floor a small browser window is clamped to, and the 205x50 a full-screen window gives at the default font. The hit test is not size-neutral - a row is worth about two world units of height at 80x24 and about one at 205x50 - so a check pinned at one size can pass while the game a player sees misses. Anything read off the painted canvas inherits that, and not only the hit test: the boss encounter staged every render at 80x24, where the three pickups a kill leaves are three glyphs at every depth, and at the 60x20 floor two of them landed in one cell from z=-189 out. The drawn gap between two things a fixed world distance apart narrows with the grid's width, so the narrow grid is where a legibility check belongs first.
A figure in a comment is often counted from something the code picks, and the premise is pinned as well as the behaviour. The wall figures above glyphInset in index.html are all four glyphs by the eleven font sizes the fitting settles on, because drawTunnel ramps its wall character through four depth bands - so the suite drives a quiet tunnel tall enough to cross all four, collects whatever is standing on the wall columns by position rather than by matching a list of characters, and fails if that set is not exactly the four. Collecting by position is the point: a list of characters handed to a check in advance settles in advance what it can notice. A glyph swapped for another, or a band dropped, now sends the next reader to the figures that counted the old four instead of leaving four published numbers quietly wrong.
The set is only half of what that table says, and a sorted comparison cannot see an exchange inside it: give the far wall the mouth's shade and the mouth the far wall's, and the distinct four are unchanged, every count stays non-zero, every wall colour still shifts on a warp, and the two ramp tests still pass because they walk inks. The whole suite passed with the tunnel inside out. So the bands are keyed as well as collected - each has to be drawn in its own glyph alone, which is what rejects a fifth band cut inside an existing one, and the four have to arrive far to near in that order. Whether all four were reached at all is read before those per-band checks rather than after them, which is the only order in which it can fail: a band no row reached leaves its key absent and is named as that, while read afterwards the per-band check has already rejected the same band for coming back empty, which reports the glyph rather than the band. The ordering is read a second time off the draw order alone, without the copy of the band boundaries the suite keys the first walk by, so a stale copy fails the one and not the other and the pair say which of them is wrong. That ordering is worth an assertion because the glyph carries most of the effect: read back off the canvas a cell at a time, the four bands cover 14%, 29%, 75% and 86% of their cells, so the inks span a factor of 1.33 of distance from the paper while the cells as painted span 8.2. Coverage carries the ramp and the ink trims it, which made the ink the smaller half to have pinned first.
Probes
A probe reports; it never asserts. The figures quoted in comments, tests and the changelog come from test/probes/, so a number can be rebuilt from the repository rather than taken on trust, and a number moving is something you read rather than a build that fails. Each probe prints its own method - the grid, the placement walk, the ship heights, the band and the frame rate - above its table.
The probes that fly a shot share one engagement with the tests, out of test/engagement.mjs, and run against the two real engines. A figure taken off a scratch copy of the engine can be right about the copy and wrong about the game.
glyph-tuning is the one that measures nothing. It rebuilds the figures the browser build's glyph centring quotes about itself - the per-size advance and cell table, the inset range and both its ends, the cycle of five, the flat-0.6 near miss and the glyph-and-size pair counts - out of Courier New's 1229/2048 em advance and the page's own glyphInset, read through the same module the tests load the engine through. Every one of those figures had been read in chromium against a scratch copy of the page and written into a comment, which is a number nobody can check.
Four of them still cannot be rebuilt, because they are pixel readings rather than arithmetic: the hinted-against-precise comparison, the junction levels, the left bearing at the two narrowest-slack sizes, and the ink widths of the four wall glyphs. The probe lists each with the instrument that took it and what it concluded, rather than leaving the number on its own, and this repository takes no chromium dependency to automate them - every probe in the rig runs in node, and a browser probe would split that in two. A fifth figure, the overhang past the cell, the probe does reproduce exactly, but off a fitted premise rather than a reading, and it says so where it prints it.
overlay-anchor is the one that flies nothing. It measures where the browser build's touch controls land against the character grid underneath them, whether the label inside a control fits the box the band sized, and what a floor under that label would take away at each size it could be set to, over every viewport the game will play at, so it takes no grid and no build - a terminal has no overlay, and the viewport is the thing being walked rather than a setting. It resolves the two control sizes out of the page's own stylesheet through test/page-style.mjs, which is the module test/browser-shell.test.mjs resolves them through as well: a probe with its own copy of a length goes on reporting about a rule the page no longer has, and this one's figures are quoted in the changelog.
drop-clip is the one that reports a time rather than a count. It stages the frame a boss kill actually arrives in - the tunnel axis strung with the player's own bolts, a kill raised through the engine so its burst is in the scene too - and times the whole render twice, once with the three pickups in the air and once without, so the figure answers whether a frame got slower rather than how long one pass took. The pass instrumented on its own would be a copy of the thing being measured, which is the rule the rig opens with. A timing is a property of the machine it ran on and not of a build, so the two builds' numbers sit side by side in its table and are never compared, and nothing in test/parity.test.mjs pairs with it. It reports the spread across repeated passes for the same reason: the two figures land inside each other's noise, so one pass cannot tell a cost from a warm cache, and a negative delta in that spread is the honest answer rather than a fault.
versus-half is the one that reports a width. The browser build's split screen gives each pilot half the grid and the half carries the whole HUD, whose three readings are placed from three different edges - SCORE pinned left, DIST centred, SHIELD pinned right - so whether they clear each other depends on the width and on how many digits each has reached. Both of those are facts about a flight, so the probe flies one: a seeded run per build, held alive, out to twenty minutes, with the score and the distance read off it at six marks. It then sweeps half widths at each mark and takes the narrowest at which the readings stop overlapping, counting the columns of that row written twice rather than carrying its own copy of the three formats - a probe that restated SHIELD: 100% would go on reporting about a HUD the game no longer draws. 40 columns holds the first thirty seconds, 42 holds out to five minutes, and 45 is what the ten and twenty minute marks want - the figure VERSUS_MIN_HALF_WIDTH carries. The tunnel's own mouth at that width is printed beside it, counted off the drawn wall columns, so what the floor costs the player sits next to what it buys them. Both builds, because the HUD is shared and the floor is therefore a figure about the HUD rather than about the browser; only the browser has halves to apply it to. The figure is now the line a half's readings are shortened below as well as the width a round opens on: what the probe measures is the row drawHUD writes, and under the floor drawVersus lays that row down again in the fullest form that clears.
Most of them stage that engagement - one target parked in an emptied run, the ship steered onto it - which isolates the shot, and is not the shape a fault turns up in. free-flight is the other end: a run the engine opens for itself, sixty obstacles in the tunnel and volleys overlapping, watched frame by frame, with the walk over the firing columns no flown engagement ever reaches printed underneath it.
Every walk here gives the same numbers every time. A staged walk always did, because it places what it flies; the run the engine opens for itself did not, because it drew its sixty obstacles from an unseeded Math.random. Quoting one of those passes put figures in this repository that the next run fell outside of, and restating them as spreads did not help - two ten-pass runs of the same walk disagreed with each other. So the engine takes a seed: seedRng pins every draw either build makes, both builds draw the same sequence from it, and free-flight flies a fixed set of seeded worlds. --passes N narrows it to the first N while you iterate; a figure quoted anywhere here is taken over the whole set, or at the first seed alone where the suite pins it, and each table says which. The game itself stays unseeded - a run nobody can predict is the point.