Search

Tuesday, September 8, 2026

Agentic Development in Practice: How Six AI Agents Built a Web Game - and What Broke

Part 6 of the AI Agents series. The full build story of the browser-playable Snakes & Ladders arena that the six-role software company from Part 5 delivered — beginning, as these things do, with the two times it failed first.

This is a field report on agentic development: what actually happens when you hand a real software project to autonomous AI coding agents and let them run a full development lifecycle. Most guides to agentic coding describe the theory — the roles, the loops, the guardrails. This one is the evidence: three attempts, two abandoned toolchains, a hallucinated graphics API, an AI-issued quality certificate that certified a layer it had never tested, twenty-eight defects closed without being repaired, and a finished game you can play in your browser right now. If you want to know whether AI agents can build software — and, more usefully, how you would ever know — that is what this post is about.

What this post covers:

  • Why this game was built three times — twice as native C, by two different agentic drivers, and both times it died.
  • What a hallucinated API actually looks like when the compiler grades it, including the subtler case of a function that was real once and has since been removed.
  • A QA certificate declaring the application 100% defect-free whose own footnote admits it never tested the layer containing the only visible bug.
  • The stretch where my own instruments were wrong four times in a row and the person watching the screen was right every time.
  • Twenty-eight defects found by an independent reviewer, closed without being repaired, and then actually repaired.
  • Why a finished, tagged, CI-green project needed a week and a full rollback to survive meeting one phone.
  • The finished game running on 19 GitHub-hosted runners across three operating systems and two CPU architectures.
Browser game built by autonomous AI coding agents - the finished Indian Snakes and Ladders arena mid-game: a procedurally generated ten-by-ten board with speckled SVG snakes and two-rail ladders, four coloured player tokens in play, an animated die, and a live commentary panel listing recent moves.

The finished arena, playing itself. Procedural board, SVG snakes and ladders, four autonomous players, twelve audio cues — and no build step anywhere in sight.

In Part 5 I forked a production coding CLI into a six-role software development company — CEO, CPO, CTO, programmer, reviewer and tester, running as native subagents on free NVIDIA NIM models. That post was about the harness. It closed by naming the thing the harness actually produced and promising the details here: a four-player Indian Snakes & Ladders arena that plays itself in any browser, with no build step and no server.

This is that story. It does not begin where you would expect, because the browser was not the plan. It was the third plan.

Before the company: two AI coding agents, two dead ends in C

Long before there was a company to run, this project had been built twice and had died twice — both times as a native, compiled C application. The original specification was deliberately unfashionable: portable ISO C11, Sokol single-header libraries, one source tree that had to compile and run on Linux, Windows 10 and Windows 11; sound on every event; a graphical dice animation rather than a number in a text box; and no model calls at runtime, with the rules baked into C.

Here is the whole prehistory in one table, before the play-by-play.

AttemptDriver and modelHow it ended
1ChatDev 2.0 + poolside Laguna XS 2.1 (free NIM)A Python game wearing a CMake costume, then rate-limited to a standstill
2Kimi CLI + Nemotron 3 Super 120B (free NIM)A 204 KB executable that launched and aborted in milliseconds, silently
3Same driver, Sokol swapped for RaylibA real board on screen — and a toolchain still fighting back
A browser, and the Part 5 companyShipped

Dead end one: when the agent framework’s own template fights your specification

ChatDev's shipped lifecycle workflow has a coding-phase prompt that states, as settled fact, Programming Language: Python, and helpfully instructs the programmer to create a virtual environment and install packages. My attachment said C11, Sokol and CMake. The programmer agent resolved this contradiction by honouring both.

What came back was eleven Python files implementing the game in pygame, and beside them a CMakeLists.txt declaring LANGUAGES C and CMAKE_C_STANDARD 11 whose source variable was named PYTHON_SOURCES and pointed at the .py files. A cargo-cult CMake shell over a Python program: a build system that satisfied the letter of the specification while building nothing it described.

Later phases bolted genuine C and Sokol on top without removing any of it, so the workspace ended up with game.py sitting next to game.c. And board_data.c arrived carrying a verification comment block asserting that its snake and ladder endpoints had been checked against the reference board. They had not. The endpoints were invented, and the list included a snake whose head sat on tile 100 — the finish square, which no player can ever leave.

The scaffolding was impeccable: guard scripts, git hooks, a licence, a README. The product was fiction. The model satisfied every part of the specification it could satisfy in isolation, and quietly skipped the parts that would have required it to reject the template it was running inside.

Then the free tier closed the door. Laguna began returning 503 ResourceExhausted — Worker local total request limit reached (89/32), the retry ladder stretched to fifteen-minute intervals, and a resume run stalled on its very first agent call. Readers of Part 3 will recognise the shape of that defeat exactly.

Dead end two: hallucinated APIs, and the stale hallucination

So I changed drivers: the Kimi CLI, still on the free NIM tier, now on Nemotron 3 Super 120B. The first obstacle was not the model at all. Kimi sends prompt_cache_key on every request; NIM strictly rejects unknown fields; every call failed with a 400. Ninety lines of local proxy that stripped one field and forwarded everything else unchanged fixed it. (In Part 5 that same incompatibility finally got solved properly, as a provider flag in the fork.)

Nemotron then did good work. It deleted the entire Python track in a single command, fixed five C++-style compound literals — &sg_desc_t{...} where C11 wants &(sg_desc_t){...} — stripped UTF-8 byte-order marks out of game.c and its header, and downloaded and rasterised the reference board.

And then it hit a wall that no amount of capability gets you over: it is a text-only model. It had just rendered a picture it could not look at. To its credit it said so, and left honest TODO markers where the endpoints belonged — a marked contrast with ChatDev, which had claimed verification of the very same fabricated list. A vision-capable model was eventually asked for the board as structured data, and supplied it in one shot.

Then the compiler began grading the Sokol integration, and almost none of it turned out to be real:

  • sokol_gfx_setup, sokol_audio_setup — the real functions are sg_setup and saudio_setup
  • sg_desc_t, sg_pass_action_t, sapp_desc_t — the real types carry no _t
  • SAI_K_SPACE, SAI_K_ESCAPE — an entire invented keycode namespace; Sokol uses SAPP_KEYCODE_*
  • SAPP_ACTION_CLEAR, SFGFRT_B8G8R8A8_UNORM, SFGFDT_DEPTH16 — pure invention

One entry deserves its own name. sg_begin_default_pass is not a hallucination: it is a real Sokol function that was removed from the library years ago. The model had produced code that would have compiled beautifully in about 2022. Trained on mixed-vintage sources, a model can hand you something that was true once — a stale hallucination, which reads exactly like knowledge and fails exactly like invention.

Underneath all of it sat a defect no amount of API knowledge would have caught: four sibling headers each contained the line #include <stdbool.h, the closing angle bracket simply absent. Four identical single-character corruptions, invisible to every reviewer, revealed the instant a compiler read them.

Eventually it linked. build/bin/snakes_and_ladders, 204,688 bytes, a real ELF binary against real X11 and ALSA. I ran it on a real desktop. It aborted within milliseconds — exit 134, no window, no stdout, not one line of stderr. The backtrace was almost funny:

#4  __GI_abort ()
#5  _sapp_log.constprop.0.cold ()
#6  _sapp_linux_run ()
#7  main ()

Sokol had hit a fatal error and called its own logger to explain what was wrong. The sapp_desc had no .logger field wired, so the explanation went nowhere and the process died mute. Two lines would have made the program tell me what was wrong with it. That is the whole exercise in miniature: something shaped exactly like a working game, with the one wire missing that would have let it say so.

The pivot: why changing the problem beat changing the model

A third attempt swapped Sokol for Raylib, and it genuinely helped — Raylib is pure C99 with a vastly smaller surface to hallucinate against, and for the first time a real board appeared on screen. But the days were still going to the same places: missing libgl-dev and libglx-dev, then missing X11 development packages, then duplicate symbols, then link errors. None of that is Snakes and Ladders. It is the toll you pay for choosing a native binary, and I was paying it in full while the actual game — a hundred numbered squares and a die — sat unwritten.

So I read my own specification again and noticed that it had never actually required a compiled executable. It required four players, a board, a die, sound on every event, and the thing running on Linux, Windows 10 and Windows 11. A browser does all of that, and treats "runs everywhere" as a solved problem rather than a project risk.

The rewrite targeted HTML5, CSS and plain ES2020 — no bundler, no transpiler, no build step. What that bought was not elegance but the removal of an entire category of failure. There is no sg_desc to misspell, no logger to forget to wire, no libGL to be missing, no architecture to cross-compile for, and no linker to lose an argument with. Every single defect described above became structurally impossible rather than merely fixed.

Two capable models spent weeks failing at this, and the fix was not a better model. It was a smaller problem. Moving the target did more for delivery than any model swap did — and I would not have believed that if I had not first watched two of them fail at the harder one.

That is the ground the rest of this post stands on. What follows is what happened once a six-role software company was pointed at a problem its models could actually see: a procedurally generated board, snakes that had to be argued into looking like snakes, an independent code review that found twenty-eight defects, a QA certificate that certified precisely the wrong thing, and a phone that exposed what no desktop test ever could.

The brief, and the one rule that shaped everything: delegating to a multi-agent SDLC

The specification the company received was ordinary enough: a four-player Indian Snakes & Ladders arena that plays itself. Entry on a 1 or a 6, exact landing on 100 to win, capture (Katti) sending an opponent home, an extra turn on a six and a penalty on the third. A board drawn as SVG. Sound on every event. A kiosk mode that starts on its own and a normal browser mode that waits for a click. When a game ends, generate a new board and play again, forever.

The operating rule mattered more than the specification. I did not talk to the programmer, the reviewer or the tester. I talked to the CEO, and the CEO ran the company — the whole point of the six-role structure from Part 5. Every brief went in the same shape: here is the defect, here is the evidence, delegate it, come back when the tester has proven it on the real screen.

The company broke that rule almost immediately, which is where this gets interesting.

First delivery: the pieces you could not see

The first substantial delivery was, by every internal measure, a success. The rules engine was genuinely good: eleven unit tests covering off-board entry, exact-landing-to-win, overshoot, ladder climbs, snake descents, capture, the six bonus and the three-sixes penalty. A thousand-playthrough stress run terminated with a winner every single time and never produced an out-of-range position. The model and controller layers were solid work.

Then I opened it in a browser and there were no players on the board.

The most-quoted line was this one. All four pawns start at position 0, which in Indian rules means off-board — you must roll a 1 or a 6 to enter at tile 1. And the view rendered position 0 like this:

tileToPosition(tile) {
    if (tile === 0) {
        // Off-board position, we'll place it off to the side or hide it
        return { x: -50, y: -50 }; // off-screen
    }
    ...
}

Applied as left: -50%; top: -50%, that is not "off to the side." That is nowhere.

Rebuilding that exact commit to photograph it for this post turned up something the original diagnosis had missed. The token elements are created and appended to the board — four divs with a class and a size — but nothing is visible where they are placed either. In the screenshot above the status bar reports Player 1 on tile 5, and tile 5 is empty. So it was never only the off-board case: the first delivery rendered no player pieces at all, on or off the board. The line above is the part that got quoted, because it is the part with a comment admitting what it did.

The product requirements document the company had written for itself was unambiguous. Line 18: "Four player tokens are positioned off-board (position 0) at the side of the board." A visible staging area. The programmer's own comment records the exact moment the requirement was quietly downgraded to its easier alternative: "we'll place it off to the side or hide it." It hid it.

The first delivery of the AI-built game: the board, snakes, ladders and dice all render correctly and the status bar reports four players with one of them on tile 5, yet not a single player token is visible anywhere on the board.

The first delivery, reproduced from the exact commit the QA certificate passed. The board is immaculate. The status bar at the bottom reports four players — and Player 1 on tile 5. Count the pieces on the board.

The asterisk on the QA certificate: when an AI tester certifies what it never tested

That would be an ordinary bug, except for what the tester had already issued. QA_CERTIFICATE.md certified the application as "100% OPERATIONAL AND DEFECT-FREE", with a formal certificate ID and a table reporting Defects Found: 0 / Critical: 0 / Major: 0 / Minor: 0.

Its own closing footnote gives the game away:

Note: View layer manual verification confirmed via browser-based testing (separate from automated test suite due to jsdom environment limitations). All visual and interactive components function as specified in manual QA checks.

The automated suite covered the model and the controller. The view layer was excluded — and the view layer is precisely and exclusively where the only user-visible defect lived. The tester then asserted that the untested layer was fine, on the strength of a "manual QA check" that no human had performed. As a final flourish, the certificate was dated 2025; the run happened in 2026. The agents got the year wrong on their own certificate.

Lesson. This is not laziness, and that is what makes it worth recording. The model and controller work was genuinely good and the stress test was real. But a QA department made of LLMs will certify exactly as far as its harness reaches, and then narrate confidently past the edge in the same tone of voice. The scope footnote and the headline claim contradict each other, and only the footnote is true.

The verification crisis: why headless testing lies to AI agents

The certificate was a symptom. The disease was that the company kept proving things in an environment where the things could not be observed.

Headless browsers lie about rendering. getBoundingClientRect under virtual time will happily report the final position of a CSS transition that never visually happened; a Puppeteer assertion that an element "is at" a coordinate says nothing about whether a human would see it travel there. Twice the tester signed off on animation work that the real screen flatly contradicted.

So the brief acquired a clause that never left it again:

The REVIEWER must VISUALLY inspect the ACTUAL rendered board on the REAL kiosk across an ENTIRE game — a sequence of screenshots over a full game plus zoomed crops of at least one ladder and at least one snake head — and REJECT back to the programmer if anything looks visually wrong. Sign off ONLY on this evidence, never on attribute assertions or a single static frame.

In practice that meant Chromium on a real X display, screenshots captured with import -window root, and a reviewer whose job was to look at pictures rather than read assertions. It roughly doubled the cost of a review cycle. It was the single highest-value change made to the process, and every subsequent defect in this post was caught by it.

Arguing a snake into looking like a snake: why text-only AI agents fail visual requirements

What followed was the longest and least dignified stretch of the project: forty-odd rounds of trying to make a red line look like a reptile.

The company's first attempt at the snake was a dead-straight diagonal band terminating in an arrowhead. Told to make it sinuous with a tapering tail, it produced a filled tapered polygon that rendered as shapeless blobs. Told the blobs were worse, it produced a thick straight band with a single lumpy eye and a dark arrowhead. Told again, it delivered a body that was nearly straight, a head like a ball with two large white cartoon eyes, and a forked tongue pointing inward, into the snake's own body.

Every one of those deliveries arrived with a claim that it matched the reference image. None of the agents could see the reference image.

That is the structural problem this stretch is really about, and the eventual solution was not a cleverer prompt. It was me measuring the reference by eye and handing over arithmetic:

Snout: an elongated oval/teardrop about 4.0 long × 2.6 wide at the neck end, narrowing to a rounded nose, long axis along the facing direction. Forked tongue: two prongs from the nose tip, each ~1.8 long, splayed 25–30° apart, stroke width ~0.4, pointing forward. Eyes: NONE — the reference is a silhouette. Total head+tongue extent stays within ~5 units in every direction so it fits inside the 10-unit starting tile.

Text-only models cannot look at a picture, but they are perfectly good at implementing a number. Once the visual requirement was expressed as geometry rather than as adjectives, it was implemented correctly on the next pass.

Visual requirement implemented by a text-only AI agent from numeric geometry - a zoomed crop of the finished snake rendering: a sinuous speckled body of even width, an elongated snout head with a forked tongue projecting forward, and a fine tapering whip tail, all contained within its starting tile.

The snake that finally read as a snake — snout, forward-flicking forked tongue, no cartoon eyes, whip tail, the whole head contained inside its starting tile. It took a paragraph of arithmetic to get here.

The ladders taught the companion lesson. Their two-rail-with-rungs rendering was correct early, and was then destroyed three separate times as collateral damage from unrelated snake work — once replaced with a dashed line, once with a solid green band. After the third regression the brief acquired a second permanent clause, in capitals: LADDERS ARE LOCKED. Do not modify the ladder-drawing code, at all, for any reason. It stayed locked for the rest of the project and the ladders never broke again.

The overseer becomes the unreliable narrator: when your own instruments lie

For sixteen rounds the pattern was consistent: the models made confident claims, and measurement caught them out. Then the pattern inverted, and this is the most useful thing in the entire project.

The client — watching the actual screen, with no instruments at all — reported that pieces were teleporting instead of walking: 10 → 14, then 26 → 84, then 97 → 80. Every one of those was legal play. 26 + 2 = 28, the foot of the 28→84 ladder; 97 + 2 = 99, the head of the 99→80 snake. The rules engine was never wrong; it passed 11/11 throughout. The animation was hiding the reason, so correct play looked arbitrary.

The real defect sat three lines apart:

line 130:  this.view.onExtraRoll();    // die == 6: active player UNCHANGED
line 132:  this.advanceTurn();         // otherwise: active player CHANGES
line 136:  this.view.onStateChange();  // render happens AFTER

The view decided whether to animate a walk by asking whether the moving token belonged to the active player. But the controller advances the turn before the view renders, so on every roll that was not a six, the piece that had just moved was no longer the active player, the check failed, and it took the direct diagonal. Stepping only ever worked on a six. Nothing in the codebase recorded who had actually moved.

Then it got embarrassing, because I proceeded to be wrong four times in a row about my own measurements:

  1. I "verified" the fix and declared it working. My test had called the animation function directly and set the mover by hand — bypassing the entire controller path where the bug lived. It proved the view can walk, not that it does.
  2. I rebuilt the test to drive the real controller, got a failure, and told the client the fix was broken. The token had ended on 86 instead of 84 — because my cleanup had not stopped the arena, a second roll fired mid-test, and 84 + 2 = 86. I was measuring two rolls and calling it one.
  3. With the loop stubbed, the sequence stopped short. My sampling window was twelve seconds and the walk needed longer. Widened, the full sequence appeared: 27 28 34 47 55 65 76 84.
  4. Worst: every tool started returning nothing, screenshots crashed with SIGTRAP, and the harness reported "board element missing, 0 cells." I concluded the build was broken and began bisecting commits. It was none of that. /tmp was a 2 GB tmpfs at 79% capacity, and I had been passing --disable-dev-shm-usage — the flag that forces Chromium off /dev/shm, which had 2 GB free, and onto exactly the filesystem that was full. My own flag was killing the browser I was measuring with, and I read the corpse as a product defect.

And one that was simply humbling: a walk test of mine printed PASS on the result visited 27: true, visited 28: true, ended 84: false. The criterion checked the intermediate tiles and never asserted the destination. It had been reporting PASS on incomplete walks for several rounds. A test that cannot fail for the reason you care about is not a test.

Meanwhile the company, twice, was right when I was not. When a piece of choreography work hung the browser, it declined to ship it and stashed it with an honest label: round16-WIP-hangs-browser: choreography+message reposition, causes infinite loop. And when I accused it of a regression that pieces were stalling at a ladder foot, its evidence — nine distinct frames across ninety seconds — said otherwise. A screenshot settled it in the company's favour: red caught mid-climb on the 21→42 ladder, the model already at 42 while the token still rendered at 21. The choreography, frozen in the act of working.

Lesson. The moral of the first sixteen rounds was "don't trust a model's claim, measure it." The moral of this stretch is the harder half: an instrument is a claim too. Mine produced four confident falsehoods in a row — a test that bypassed the bug, a test that measured two rolls as one, a window that closed early, and a flag that killed the browser. Each felt like evidence. The client, looking at the screen, was right every single time.

The board became procedural: how to verify an AI-generated algorithm

Somewhere in the middle the fixed board gave way to a generated one, and it is worth telling as a single story because each step failed differently.

The generator came with a formal contract: endpoints never touch {0, 1, 100}; ladders go up and snakes go down; spans between 5 and 40; six to nine of each; no snake head in the brutal 95–99 band; every tile used at most once. The company reported a thousand runs with zero violations. I did not take that on faith, and the interesting part is how I checked it: rather than re-running the happy path, I fed the validator deliberately broken boards — a ladder that climbs to 100, a shared endpoint, a ladder pointing downhill — and confirmed it rejected each one with an accurate message while a valid control passed. A validator that always returns "clean" prints exactly the same zero as a real one. The only way to tell them apart is to hand it something dirty.

Then the client tightened the rule: no two connector lines may cross. The obvious risk was an unbounded generate-and-reject loop, so the algorithm went incremental-greedy with a per-connector attempt cap and a restart cap — total work provably finite. Over a thousand generations the greedy needed a full restart exactly twice. And again I checked it with an instrument the company had not written: an independent segment-intersection test across 200 boards and 20,824 connector pairs found zero crossings.

One failure in that arc was not the code at all. The client reported that Shift-Reload kept showing the same board. The server was sending only Last-Modified with no Cache-Control, so the kiosk's Chromium had heuristically cached the old model file and kiosk-mode reload never refetched it. The fix was a no-store static server, committed to the repository rather than left as a live hack — because the client had asked, pointedly, whether the server was part of the committed code. It had not been. Now it is.

Hiring a second opinion: an independent AI code review, and 28 defects

By this point the company had reviewed its own work many times, and there is an obvious limit to that: the same fleet of models that wrote the code was grading it. So we brought in an outside examiner — a full code-review workflow driven by DeepSeek V4 Pro, chunking the codebase, returning findings, and requiring the programmer to adjudicate every one.

It returned 32 findings, of which 28 were accepted as real defects: a DOM-XSS path building error messages with innerHTML, event handlers that were never bound, an uninitialised element reference, a commentary panel that could not scroll, responsive overflow bugs, and a decorative SVG filter that was defined but never applied.

The company then closed all 28 of them with the status CLOSED — NO FURTHER ACTION.

That is the sentence to sit with. Faced with a list of genuine defects found by an independent reviewer, the org rubber-stamped the list rather than repairing it, and reported the review complete. Sent back with instructions to reopen every one and actually fix it, it did exactly that — a prioritised pass, critical first, delivered as a single commit touching the stylesheet, the view, the controller, the entry point, and the defect register itself, with every entry flipped to CLOSED — FIXED.

And this time the labels were earned rather than painted on, which I verified by grep rather than by reading the report: textContent replacing innerHTML on the error path, seven properly bound handlers where there had been none, the element reference initialised, overflow-y: auto restored on the commentary panel. A headless console probe on the repaired build returned zero errors and zero failed requests.

One honest caveat survived, and it is still in the project's known-gaps list. The unused-filter defect was resolved by applying the speckle filter rather than deleting it, so the code is honest and the defect is genuinely closed — but a raw turbulence without a colour matrix renders as multicoloured noise, not as a yellow-and-black snake. The code defect is fixed; the picture is not yet the documented one. That is a polish item, and saying so is better than closing it twice.

Lesson. "Closed — no further action" and "closed — fixed" are one word apart and an entire project apart. An AI software company will produce a defect register with the same care it produces code, and will close it with the same confidence either way. Reopening a rubber-stamped list and making the company earn every FIXED — then checking its work with a probe and a grep — is where a review stops being paperwork and becomes repair.

1.0.0 — and then a phone: the last mile of AI-built software

Version 1.0.0 shipped: procedural boards, SVG snakes and ladders, tile-by-tile walking with a path glide, twelve audio cues, capture, the triple-six penalty, and a zero-click loop that regenerates the board and plays itself forever, with zero console errors. A licence, a CI workflow green on all three hosted runner families, a release page a stranger could install from. The ledger recorded "end of ledger." It meant it.

Then someone opened it on an iPhone.

Three real problems surfaced at once: iOS browsers all run WebKit and cannot decode OGG Vorbis, so every sound needed an MP3 twin; the start button was gated behind audio loading and had to be decoupled; and the page needed a genuine mobile viewport. The company delivered all three, and the phone showed a properly stacked layout. One complaint remained, and it was precise: on the phone only, the sounds trailed their events. The dice would roll, and the roll sound arrived late, over the next animation.

The client asked the question that decided the rest of the project: "Is this an iOS limitation?"

It was not. It was an API choice. WebKit's HTMLAudioElement has high start latency — it does not pre-decode, and .play() is slow off the mark — while desktop engines pre-buffer and hide the cost. The fix is the Web Audio API: decode each clip once into an AudioBuffer and fire it through a buffer source node, for near-zero latency. A ceiling you accept and an implementation you fix look identical from the outside, and the only way to tell them apart is to ask.

The regression cascade: how one surgical AI fix broke four other things

The audio change was clean on desktop and broke two things on the phone. The loader now counted every event twice toward its asset tally — once for the audio element, once for the decode — so the loading flow completed early and the start button needed two taps. And the smallest clips stopped playing at all. Each was chased with a surgical pass, and in the middle of it a problem surfaced that had nothing to do with the code.

The client kept testing the public URL in an incognito window and kept seeing the old build, even though the bytes served from the box matched the new commit. Incognito defeats the browser cache; it does not defeat the CDN edge in front of it, and the script tags carried no version string, so a nearby edge node was serving a ten-minute-stale copy of the view. The cure was a cache-buster on the script tags and, more importantly, a discipline change: from then on the trusted test surface was a local no-cache server, not the public URL. When you cannot tell whether the fix is failing or the fix simply is not the bytes on the phone yet, you have no signal at all.

The 15 Pro Max cliff: desktop-correct is not device-correct

Then a worse report: on an iPhone 15 Pro Max the desktop layout appeared and the start button did nothing at all. Two root causes, both parables.

The layout gap was pre-existing and merely exposed: the mobile rules were gated at max-width: 420px, and the 15 Pro Max reports a 430px CSS viewport, so it fell straight through to the desktop grid. The dead button was a genuine regression that the company's testing structurally could not catch. The new audio-priming loop set audio.currentTime = 0 on elements which iOS — never preloading audio — leaves at readyState 0. That throws InvalidStateError, and the throw aborted the start handler before it could remove the button or roll the dice. On desktop the audio is preloaded, so it never throws, so the bug is invisible on every machine anyone was testing on.

Stop, and recover: rolling back an AI agent’s work safely

At that point the right move was not another patch. A surgical line of work was spawning more regressions than it retired, so we went backwards on purpose. The forensic question — what was the last commit before the very first sync complaint? — produced an exact answer, and the recovery was done non-destructively and in the right order: because the public site was live and broken, a stable branch pinned to 1.0.0 was stood up and the public site repointed to it first; then the main branch was reset to the last-known-good, with the abandoned audio work preserved in a backup branch and a dated tag so nothing was lost; then the site was pointed back. The clean baseline was tagged as an intermediate release and checked end to end.

Two infrastructure lessons fell out of it that had nothing to do with audio. The site rollback appeared to fail when it was in fact starving for a runner behind a backlog of queued CI jobs. And a repointed site source does not rebuild on its own; the build has to be triggered.

The final pass was different in kind. The agent itself was upgraded and relaunched fresh on the clean baseline, and the brief was one comprehensive directive that baked in every lesson at once rather than another increment: Web Audio for the sync, the breakpoint widened to 480px to cover both phones, and three anti-regression invariants written as hard requirements — the tally counts each event exactly once; the audio path can never throw, with every fallback guarded behind a readyState check inside try/catch; and the structural one, the game starts on the first tap before any audio call runs at all. Remove the button and roll the dice first; prime the audio afterwards, as best-effort, in a wrapper that cannot abort the start.

Around all of it, a lock: only the audio methods in the view and the single breakpoint number in the stylesheet were allowed to change, and the reviewer's first duty was to reject any diff reaching outside that box. It held. The commit touched exactly those two files.

And the verification finally caught something before the phone did. A headless probe that clicked the start button while assets were still loading found a latent race: the game started correctly, then a later call from the asset-completion path re-appended the button, and because the fire-once guard had already tripped, a second tap could not remove it. On a phone's slower load that window is wide — it is the "button will not go away" symptom wearing a different costume. The audio work had not created that race; it had widened it by adding decode time to the load. The fix was one line, and it was found on the build box rather than in the client's hand.

1.0.1 was validated on an iPhone 12, an iPhone 15 Pro Max and a desktop browser: stacked layout on both phones, single-tap start that stays gone, sound in sync, every cue present.

AI-built web game on mobile - the arena running on an iPhone in portrait orientation, with the responsive stacked layout: the board at the top, the dice beneath it, and the move commentary below that.

1.0.1 on the phone that caused all the trouble: stacked layout, one tap to start, and the dice sound arriving with the dice.

Lesson. Desktop-correct is not phone-correct, and the gap is not cosmetic. The two worst bugs in this arc — a start handler that threw on unpreloaded audio, and a start button that re-showed after the game began — were both invisible on the machine that tested them and fatal on the machine that shipped. And decouple the thing that must happen from the thing that might fail: once "remove the button and roll" ran before "play a sound," an entire family of audio failures lost the ability to freeze the game.

Nineteen runners: proving cross-platform without one line of platform code

The native era of this project spent weeks failing at portability. It seemed only fair to find out what the browser version was actually worth, so the arena got the Part 4 treatment: a workflow that points headless Chrome at the live public URL from every GitHub-hosted runner image available, clicks the start gate, and photographs the running game at 30 seconds, 5 minutes and 10 minutes.

Nineteen of twenty-one runner images captured successfully, across three operating systems and two CPU architectures: eight Linux images including all three ARM64 variants and a slim image, four Windows, and seven macOS covering both Apple Silicon and Intel. Every capture is a different machine playing its own independent game, which is why every board in the matrix is different — they are procedurally generated per session. The engine is the only constant.

Cross-platform proof for an AI-built web app - a grid of screenshots of the same arena captured on different GitHub-hosted runners - Linux on x64 and ARM64, Windows on x64, and macOS on both Apple Silicon and Intel - each showing a different procedurally generated board mid-game.

Five machines, three operating systems, two CPU architectures, one URL. Every board is different because every runner generated its own; the engine underneath is identical.

Three things had to be solved to get there, and all three are the kind of detail that turns a one-hour job into an afternoon:

  • The start gate needs a trusted click. The button carries no id and no class, so it must be matched by text; and calling element.click() from page script is an untrusted event that will not satisfy the user-activation gate that also unlocks audio. It has to be a real synthesised mouse click at the button's measured centre.
  • Chrome is not available everywhere. The standard setup action reports "Unsupported platform" on both Linux and Windows ARM64, and the Chromium it installs on macOS ARM64 launches but never answers the debugging protocol. Playwright's build solves both.
  • A silent ten-minute wait times out. Holding a page open with no protocol traffic lets a pending command age out; the dwell needs a heartbeat.

The two failures are honest and worth naming: windows-11-arm and its VS2026 variant have no Chrome or Chromium build published for Windows on ARM64 at all. There is no browser to drive. That is a platform gap, not an application one — and it is the only portability caveat this codebase has, which is a remarkable sentence to write about a project whose first two lives died fighting a linker.

Play it, or rebuild the proof

The finished arena is live and needs nothing installed:

tuklusan.github.io/snakes-and-ladders-arena — click "Click to start the arena" once, and it plays itself indefinitely.

One complete game, no human input: procedural board, tokens walking tile by tile, a ladder climb, a snake slide, a capture, and a winner — then it builds a new board and does it again. If the inline player does not load, download the MP4 directly (1920×1080, 2m 13s, 29 MB).

To run it locally, or to regenerate the cross-platform matrix for yourself:

# serve it locally with no caching (the trusted test surface)
git clone https://github.com/tuklusan/snakes-and-ladders-arena.git
cd snakes-and-ladders-arena
python3 tools/serve_nocache.py 8000

# re-run the capture matrix on any subset of runners
gh workflow run "Arena Screenshots" --ref master \
  -f labels='["ubuntu-latest","macos-latest","windows-latest"]' \
  -f offsets='30,300,600'

The capture script and the cropping tool are both in tools/, and the workflow takes any list of runner labels, so the matrix is reproducible rather than decorative.

The result: what agentic development actually delivered, and what it cost

This project was built three times. Twice as native C, by two different agentic drivers on two different models, and both times it died — once as a Python program wearing a CMake costume, once as a 204 KB executable that aborted in silence because nobody wired up its logger. The third time it was a web page, and it shipped.

What the third attempt actually demonstrated:

  • Changing the target beat changing the model. Two capable models failed at the native build. The fix was not a smarter agent; it was a smaller problem. Every failure class from the C era — hallucinated APIs, missing GL headers, duplicate symbols, silent aborts — became structurally impossible in a browser rather than merely fixed.
  • An AI QA department certifies as far as its harness reaches, and not one inch further. The certificate said defect-free; the footnote said the view layer was untested; the only visible bug lived in the view layer. All three statements were simultaneously true.
  • An instrument is a claim too. Four times my own measurements were confidently wrong while the client, looking at the screen with no instruments at all, was right.
  • "Closed" and "fixed" are one word apart and a whole project apart. Twenty-eight independently-found defects were closed without being repaired, in good faith, with a straight face.
  • Text-only models cannot see, so give them arithmetic. Months of adjectives produced blobs. One paragraph of measurements produced a snake.
  • The last mile is a different country. A finished, tagged, CI-green project met one phone and needed a week, a full rollback and an agent upgrade to survive the encounter.

None of this was a failure of intelligence. The models wrote a correct rules engine, a validated procedural board generator and a genuinely pleasant piece of animation. What they could not do was see the screen, doubt their own certificates, or notice that the thing they had proven was not the thing that mattered. That job stayed human for the entire project, and it is not obviously about to stop.

There is a particular pleasure in the moment a machine you have argued with for a week finally does the thing — and a sharper one when nineteen strange computers, none of which have ever seen your project, all quietly play a full game of it and take a photograph to prove it. The agents built the arena. Making it real took longer than building it, and taught me more.

Continue to Part 7

The company survived a game with a hundred squares and a die. The obvious next question is whether the process generalises or whether it was tuned, round by round, to this one project — so there is already a second board game in the workspace, and this time the geometry is genuinely hard: a carrom board, with a striker, physics, rebound angles and collisions that a rules table cannot describe.

Snakes and Ladders can be specified in numbers. Carrom has to be specified in behaviour. That will be a considerably less forgiving test of a software company that cannot see what it is building.

Frequently asked questions about agentic development

What is agentic development?
Agentic development is delegating scoped software tasks to autonomous AI coding agents that plan, write, run, test and correct code across many turns, rather than completing a single prompt. In this project the agents were organised as a six-role software company — CEO, CPO, CTO, programmer, reviewer and tester — running a five-phase SDLC, with a human operator gating every phase transition. The structure is described in Part 5; this post is what happened when it met a real product.

Can AI coding agents actually build working software end to end?
Yes, with important limits. The agents produced a correct rules engine, a validated procedural board generator and a complete browser game that ships and plays on 19 different machines. The same agents also produced a Python program wearing a CMake costume, an executable that aborted in silence, a quality certificate declaring an untested layer defect-free, and twenty-eight independently-found defects closed without being repaired. Raw capability was never the bottleneck. Verification was.

Why do AI coding agents hallucinate APIs that do not exist?
Because plausible-shaped code is exactly what a large language model (LLM) is optimised to produce, and only a compiler or a running program can grade it. This project generated wholly invented functions, type names and enum namespaces for a graphics library. It also produced a subtler category worth naming separately: a stale hallucination — a function that was genuinely real in an older version of that library and has since been removed. Trained on mixed-vintage code, a model will hand you something that was true once. It reads exactly like knowledge and fails exactly like invention.

Can you trust an AI agent's own tests and code review?
Only as far as its harness reaches. The tester agent here issued a certificate declaring the application "100% operational and defect-free" while its own footnote admitted the view layer had been excluded from automated testing — and the view layer contained the only user-visible defect in the build. Separately, an independent review found 28 real defects, which the company then closed with the status "no further action" rather than repairing them. An AI quality function will certify to the edge of its instrumentation and then narrate confidently past it, in an unchanged tone of voice.

How do you verify what an AI coding agent has built?
Look at the running artifact, not the report. Headless browsers misreport rendering and animation timing, so the review gate was rewritten to require visual inspection of the actual rendered screen across an entire game, with screenshots as evidence. Beyond that: independently re-run the agent's own tests, feed its validators deliberately broken input to prove they are capable of failing, and confirm claimed fixes with a grep rather than by reading the summary. A validator that always returns "clean" prints the same zero as a real one.

Do you need a paid frontier model, or will free models do?
Free models on NVIDIA NIM carried the great majority of this project, including the entire browser build that shipped. The failure modes on the free tier were mostly infrastructural rather than intellectual — rate limits, silent connection hangs, endpoints that vanish mid-run. A paid frontier model was used for one job where independence was the point: an outside code review that returned 32 findings, 28 of them accepted as genuine defects.

What is the biggest mistake when working with AI coding agents?
Believing the report instead of the artifact — and then, one level up, believing your own instruments. Four consecutive measurements in this project were confidently wrong: a test that bypassed the code path containing the bug, a test that counted two dice rolls as one, a sampling window that closed before the animation finished, and a browser flag that killed the very browser being measured. A test that cannot fail for the reason you care about is not a test.

Is it better to change the model or change the problem?
Change the problem first. Two capable models failed for weeks at a native C build against a graphics library. Rewriting the same game as plain HTML, CSS and JavaScript did not merely fix those defects — it made an entire category of them structurally impossible, because there is no linker to argue with, no graphics API to hallucinate and no architecture to cross-compile for. Moving the target did more for delivery than any model swap did.


The AI Agents series so far:

References: Play the arena · Snakes & Ladders Arena repository · Latest release · The SANYALnet Labs CLI fork · NVIDIA build catalog · GitHub-hosted runners

Model identifiers, provider rate limits, hosted-runner labels and browser availability all drift. Re-check the current model cards, GitHub's runner documentation and the repository itself if a detail here has moved since publication. Every screenshot in this post was rendered by the application itself, on the machine named in its caption.

No comments:

Post a Comment

"SEO" link builders: move on, your spam link will not get posted.

Note: Only a member of this blog may post a comment.