Boom
Build a raycaster shooter in a browser tab — then teach its enemies to see
No engine, no bundler, no library. A ray that measures distance, a world drawn one column at a time, and enemies that must perceive the map instead of reading it out of an array. This is the write-up of what actually happened when the kit — published before the first commit, on purpose — met a real build: 220 tests, one long window, a four-lane fan-out that produced no code at all, and a playtest loop that produced the entire game.
This guide is the honest half of a pair. The kit has the plan, the ten rules of engagement, the agent prompts, and the token bill. It was published before the build so the build could be judged against its own rules. This page is the judgement: which rules held, which one was only half kept, and what the game taught that the plan didn't know.
What shipped
A first-person shooter that runs in a browser tab: eight levels, four wall themes, an outdoor courtyard and a dark cellar, guards and dogs and a brute that ambushes you from a barrel and runs away when it's losing. Weapons are collected, not granted. There are touch controls, a rotate-to-landscape prompt, positional audio built from CC0 recordings, and a GPU path that turns itself on when it's faster.
Underneath: 220 tests, no library, no build step, ES modules served straight to the browser. One lead window from 00:58 to just past midnight the following day — not continuous, because the human was playing the game between rounds, which turns out to be the most important sentence in this guide.
The build in numbers
| Boom | How it compares | |
|---|---|---|
| Sessions | 1 lead window | The only build in the series done in a single window |
| Agents | 1 lead + 8 lane runs | The 8 lanes wrote nothing — see Phase Three |
| Hours working | 13.8 | The longest of the five (series total 36) |
| Feedback from Tommy | 20 messages | The fewest of the five — and it built the most game |
| Commits · tests | 79 · 220 | Series totals: 383 · 1,200 |
| Tokens written | 677K | Code, tests and notes |
| Tokens re-read | 141M | 208× what was written |
The two numbers to stare at are 20 and 141M. Twenty messages of human feedback produced eight levels, a brute, touch controls and a GPU path — because each one was specific and arrived while the game was running. And 141M re-read against 677K written is the shape of one long window: every call re-reads everything before it, so the average call carried about 490K of context. The honest sentence is not "677K tokens wrote a shooter." It's "141M tokens of re-reading did." Full accounting is on the kit page; all five builds side by side are on the scoreboard.
The spec said "single HTML file." The plan said "one writer per file across four lanes." Those cannot both be true, and the collision surfaced in the first hour. The resolution: ES modules in src/ with no bundler. The spirit of the spec — no library, no build step, view-source-able — survives; the letter doesn't. Worth noticing how early a plan starts negotiating with itself.
How a raycaster works, in one idea
For every vertical column of pixels on screen, send one ray out from the player into a grid until it hits a wall. The distance it travelled decides how tall to draw that column — near walls are tall, far walls are short. Do that 960 times and you have a 3D-looking hallway drawn out of vertical stripes. There is no 3D. There is a grid, some trigonometry, and a loop.
The traversal is DDA — digital differential analysis. Rather than creeping along the ray in small steps and asking "am I in a wall yet?", you compute exactly where the ray next crosses a vertical grid line and where it next crosses a horizontal one, jump to whichever is closer, and check that cell. Every step lands on a cell boundary, so you never step over anything and never test a cell twice.
That distinction sounds like an efficiency note. It isn't, and the tests proved it.
Phase One — the ray that measures
Stage 02's job was to replace a naive fixed-step march with DDA and assert they agree. They didn't, and the disagreement was the first genuinely useful finding of the build.
Three of 288 comparison rays disagreed — and all three were rays passing within about 0.003 of a cell corner. The naive 0.01 march walks straight over that sliver of wall and threads through the corner into the room beyond. DDA hits it. The draft described the naive approach as "imprecise", which reads as wobbly textures. The truth is sharper: it occasionally lets you see through walls.
The test now excludes near-corner rays from the equality check but keeps one assertion unconditional: DDA never reports a hit further away than naive. A test that says "these agree except when they don't" is worthless; a test that says "these agree, and separately, this invariant always holds" survives contact with floating point.
Two more Phase One findings, both about the spec rather than the code:
- An acceptance criterion was geometrically impossible. The spec asked for "iteration count under 20 per ray in a 24×24 map." A ray skimming a 22-cell open corridor crosses 22 grid lines however you traverse it — no algorithm can dodge that. Measured mean was 5.1 steps, max 25. The test asserts mean under 20 and max ≤ W+H. The spec should have said "typical ray", and now it does.
- A one-word ambiguity got frozen before it could spread. "
side= 0 means a north/south face" never says whether a face is named for where it sits or where it points. Locked in the contracts file as "which grid line was crossed", which has exactly one reading.
Both are the same lesson: the first thing tests catch is not bugs in your code, it's sentences in your spec that two people would implement differently.
Phase Two — the world, and a lesson about tests
Stages 03–05 put textures, floors and ceilings on the walls. Nineteen new tests, and no surprises in the renderer at all. Every single failure in the phase was in my test geometry, not the code:
- Twice I picked a screen column that happened to have a stone wall in it.
- Once I asserted that perpendicular distance is affine across the screen. It's
1/perpthat is — which is precisely why texture mapping needs the reciprocal. - Later, in the sprite stages: a wall cell I hadn't checked, an enemy with no line of sight to the thing it was supposed to see, and a pillar whose opaque core was hidden while its transparent margin was technically "visible".
Read the map's LAYOUT string before you write a test scene. Nearly every false failure in this build came from asserting something about a coordinate without checking what was actually standing at it. Test geometry is code too, and it's code with no tests of its own.
One real change did come out of the phase: the ray-vector arrays dropped from Float32 to Float64 after a 1e-9 tolerance test failed at 1e-8. The z-buffer stayed Float32 — it exists to be compared against by the sprite pass, where sub-1e-6 precision is meaningless. Precision is a per-buffer decision, not a project-wide setting.
Phase Three — the fan-out that wrote nothing
This is the part the kit was written to test. Rule 1 says multi-agent is a build tool, not an iteration tool: fan out exactly once, at the one moment the work is genuinely parallel. Phase Three is that moment — textures, sprites, weapon and feel are four lanes with a frozen contract between them.
Before spawning, per rule 6: main at a known commit, 60 tests green, nine commits that session, and roughly 170K of context spent getting Phases One and Two built serially.
Then: four implementer agents, four git worktrees, verified on disk at the same commit. Fourteen minutes later the monitor read zero commits and zero modified files in every worktree. Three lanes died to a stream watchdog; the fourth sat idle and was stopped. A retry on a different subagent model, with trimmed prompts and fast-forwarded worktrees, produced an identical result. Eight lanes across two attempts. Zero commits. Zero dirty files. About 68 minutes of wall clock.
Per rule 6, the build folded back to one agent and stayed there. Stages 06–09 went in serially, one commit each, tests with the work — and three lanes' worth of work took less wall clock than the two failed fan-outs had.
The finding is not "parallel was slower." It's that a subagent which is stuck looks exactly like a subagent which is thinking hard — for ten minutes, times four. There is no signal in the transcript. The only honest instrument was the thing measuring the filesystem: commits and dirty files per lane, every 45 seconds. It read 0/0 the entire time, and it was the only thing in the room telling the truth.
The correction, which is the best part
The paragraph above originally said something stronger and wrong. The notes claimed each lane's transcript "ends at its first sentence" and that a lane "cannot get its first tool call answered" — a tidy story about a model-side outage.
Tommy pushed back: "there were several work trees that ran." Going back to the lane transcripts settled it. All eight lanes ran. They made between 2 and 12 tool calls each. They read the contracts file, they read the source they owned, they ran git status — and then every one of them ends in an interrupt.
The lanes ran and read. They never wrote. The monitor's 0/0 was correct; the diagnosis of why was written from outside the transcripts and was wrong. That distinction matters, because "the lanes never started" and "the lanes started, oriented themselves, and then died before their first edit" point at completely different fixes. It is also the reason rule 10 exists — the raw record was still there to be re-read, so the mistake was correctable months later instead of hardening into series folklore.
Phase Four — the inversion
The premise of the last phase: an enemy that reads the map array is not intelligent, it's omniscient. So take the array away. Give it a forward 90° sensor cone, a belief about the world it builds from what it has actually seen, and let it explore.
Three bugs the spec did not anticipate, all of them the same species — a rule that is correct in general and degenerate at the boundary:
- The enemy never moved. With a forward-only cone, the cells behind the enemy are unknown, so the nearest unknown cell is always the one it's standing on. It had arrived before it started. Fix: the frontier excludes cells within 0.75 of the enemy itself.
- Stuck detection never fired. "Did any axis move?" stays true forever when one axis creeps by a hair against a wall. Movement is not progress. Fix: measure distance to the goal, not displacement.
- An unreachable goal is forever. Nearest-frontier re-picks the same impossible cell every tick. Fix: a 12-second blacklist.
With those three, a corner-spawned enemy classifies over 95% of reachable cells in under two simulated minutes — which the headless loop finds in about 30 milliseconds of real time. That ratio is the whole argument for simulating behaviour instead of watching it.
One test "failure" was the feature working. Coverage stalled at 78% because the enemy found the bystander player I'd parked in a corner and correctly stopped to hold position. And a stage 9 test — "line of sight → advance" — broke on purpose: under a sensor model, an enemy facing away cannot see you. Both times the code was right and the test was asking the old question. Park the player off-grid for coverage runs.
The wedged-enemy reports from playtesting resolved into three more causes, found the same way — simulation with a moving player and a metric that excluded intentional holds: sidestepping masked stuck detection, an enemy spawned facing a corner had zero free cells in its belief and therefore no goal and no reason to turn, and an enemy that finished exploring had nothing to do. Final run: 64 enemies × 8 levels × 90 seconds, zero unexplained stills.
Where the game actually came from
Here is the list of things in Boom that were not in the thirteen-stage spec: click-to-play with a grace window, a restart button, more than one level, an exit corridor that unlocks when the level is cleared, real gun sounds, vertical aim, dogs, dogs coloured differently on the minimap, ammo pickups, rapid fire, the exit shown on the map, higher resolution, pause, a knife, ammo carried across levels, lives, health kits, a flashing gold pack every third level, the level number on screen, a low-health vignette, the brute, collected weapons, first-person weapon art, a true-aim crosshair, four wall themes with windows, touch controls, the courtyard, the dark cellar, and the flashlight.
That is the game. All of it came from one human playing it between commits and saying what was wrong — and all of it was built by one agent, serial, which is exactly what rule 1 prescribes for the moment a human starts testing.
The fan-out phase produced nothing. The playtest loop produced the game. If you take one thing from this guide, take the shape of that sentence — and notice that the plan predicted it. Rule 1 was written before the build, from the Potluck post-mortem, and it was right.
Two symptoms, one cause
"Single fire goes silent after machine-gun mode" and "sounds vanish after rapid fire" were reported as separate bugs. They were one: an empty magazine with no dry-fire sound. Users report symptoms, and two symptoms are not evidence of two causes.
The exit freeze, and the worse bug that replaced it
The exit froze the game. The cause was a temporal-dead-zone ReferenceError thrown inside an async frame loop, where the exception killed the loop silently — no console error, no visible failure, just a game that stopped. It was found by pausing the page under the DevTools protocol and reading the call stack, not by reading code.
Then the fix introduced something worse: two requestAnimationFrame calls per frame, which is exponential. The same probe caught it in one run.
An async render loop needs two things you will not think of until it bites you: a try/catch that keeps the loop alive and shows the error, and a test asserting that exactly one frame is scheduled per frame. The first turns a silent freeze into a visible failure; the second makes the exponential class of bug impossible to ship.
Measuring instead of guessing
Stage 12 asked whether a GPU path beats the JavaScript renderer. The spec guessed: "probably slower at 320×200, wins around 1920." Right in direction, optimistic by 5× on where the lines cross.
Measured, headless Chrome driven over the DevTools protocol: GPU fixed cost is about 0.23 ms/frame, JS wins through 7680 columns, GPU wins from 15360 — crossover near 10K columns. The reason the guess was off is flattering to the simple approach: the JavaScript DDA is fast, 3840 columns in 0.09 ms.
Three things about the measurement itself, each of which would have silently produced a wrong number:
- A static page dump fires before async work finishes. The bench had to poll the live page over the protocol, not screenshot it.
- Headless Chrome coarsens
performance.now()to 0.1 ms. The first run reported "JS 0.000 ms" for everything. Single-frame timing is meaningless there — batch ten frames per sample. - Headless Chrome has no WebGL2 at all without
--use-angle=swiftshader --enable-unsafe-swiftshader.
Rule 7 says measure the instrument first. It was minted by a phantom 1fps reading in an earlier build, and it earned its place again here.
The emulation as a free second reviewer
Before any GPU ran, a Node test emulating f32 arithmetic caught a transcription bug: the hitOffset axis was swapped in the JavaScript mirror of the kernel. The shader was correct; the mirror was not. Write the emulation — it costs little and it reviews your translation for free. (Once the real thing ran, 1 column in 115,200 disagreed on tile and side, never on distance: a ray through an exact cell corner where f32 broke a tie the other way. Same corner-graze family as the stage 02 finding, 200 commits earlier.)
The GPU path shipped as WebGL2, not WebGPU — universal, and the pieces map naturally onto it. Its first compile failed on half, a reserved word in GLSL ES, and the failure was invisible until GL init errors were surfaced onto the HUD.
Seven things worth stealing
- Test the invariant, not just the agreement. "These two implementations match" dies on the first floating-point corner. "This one is never worse than that one" survives.
- Your test geometry is untested code. Read the map before you assert about a coordinate. Almost every false failure here was a badly chosen test scene.
- Runtime fetches resolve against the module, not the page. Boom loaded its audio manifests from
'assets/sounds/', which under a clean mount URL resolves one directory too high and silently gets nothing — the loaders fail quiet and fall back to synth, so nobody would have heard the difference.new URL('../assets/sounds/', import.meta.url)works under any mount path. - A stuck agent is indistinguishable from a working one. Watch the filesystem, not the transcript. Commits and dirty files per lane, on a timer.
- Chunky pixels are a teaching default, not a design. 320×200 hides math bugs and never makes you think about performance. Once the math was proven by tests, going to 960×600 with 128px textures touched two constants and six test literals — because everything else was already parametric in width and height. Prove it small, then turn it up.
- Fold back fast, and write down that you did. Two fan-out attempts, 68 minutes, zero code, one decision to stop. The rule that says "if parallel isn't visibly winning, fold" is only worth having if you actually invoke it.
- The raw record outranks your memory. The correction in Phase Three exists because the field notes were append-only from day zero and the lane transcripts were still on disk. A tidier record would have preserved a wrong story.
Tommy's verdict on the method, recorded verbatim after the bill came in: "I think Boom is our real first success with multi-agent. We did not overuse it, we used it like a scalpel." Which is a strange thing to say about a build whose agent lanes wrote zero lines. The lanes were never the method — the discipline around them was: one fan-out, an on-disk monitor, and a 68-minute fold-back.
He revised it two days later, once it was stated plainly that every line of Boom came from a single agent: "no wonder it felt right to me, it was back in my comfort zone." Both halves are true and the second one is the more useful. Boom is the best build of the five and the rules are why — but the reason it felt best is that the team stopped existing 68 minutes in and the work reverted to one agent, which is how he already prefers to work. A method that feels good because it quietly became your habit is not evidence the method works. That is the last thing this build has to teach, and it only became visible after the fact.