devShakib

Multi-Agent AI Workflows in Practice: 32 Browser Games, One Kit Contract, Adversarial QA

How I shipped 32 browser games with one AI agent per game against a shared kit contract, an adversarial QA pass, and the prompt fix that killed high bugs.

Generating one browser game with an AI agent is a demo. Generating thirty-two of them — in one design language, on a real site, where a stranger can land on /games/keno and actually play — is a pipeline problem, and the interesting part is not the code generation at all. The interesting part is what you do about the fact that a model will hand you a sliding puzzle that compiles, analyses clean, renders beautifully, responds to every keypress, and is mathematically impossible to solve.

I built the arcade on my portfolio this way: 32 playable games, all client-side Flutter web, compiled into a deferred JS chunk that only downloads when you open a game, best scores in localStorage, hosted on Firebase for $0 a month. It sits next to 98 free browser tools built the same way. The build ran as two batches — 24 games, then 8 more — and the gap between those two batches is the whole point of this post, because the first batch shipped 3 high-severity and 9 medium correctness bugs and the second shipped zero high-severity bugs. Nothing about the model changed between them. What changed was the prompt, and what changed the prompt was the bug list.

This post is the actual workflow: contract-first prompting so parallel agents produce one codebase instead of thirty-two dialects, fan-out mechanics and their correlated-failure tax, an adversarial QA pass with one reviewer agent per game, the trick of feeding QA findings back into the build prompt as a checklist, and — honestly — the one bug class that neither static analysis nor a code-review agent caught, where only a screenshot of the running page saved me.

One agent per game, not one agent for the arcade

The first decision is the unit of work, and it should be the same as the unit of review.

A game here is 400–800 lines in one file, one route, no shared mutable state with anything else in the app. Snake doesn't know Sudoku exists. That is close to the ideal shape for fan-out: an agent can hold the entire problem in context, produce a complete artefact, and be judged on its own without anyone reading the other thirty-one.

The alternative — one long-running agent building the arcade sequentially — sounds tidier and is much worse. Its context fills with the last four games it wrote, so game nineteen inherits the accumulated quirks of games one through eighteen. A mistake made in hour one silently propagates. And you can't review any of it until all of it exists.

The property you want is independence, not throughput. Wall-clock speed is a nice side effect; the real win is that one agent's bad decision doesn't contaminate anyone else's context. When the fifteen-puzzle agent inverted a parity check, it inverted exactly one parity check.

The corollary is that this decomposition doesn't transfer for free. It works because the units genuinely are independent. Point the same pipeline at a feature that touches a shared repository, a router, and three view models and you get twenty-four agents fighting over the same four files.

The kit contract is the whole trick

Before a single game agent ran, I wrote lib/pages/games/interactive/game_kit.dart — 421 lines of shared primitives — and reviewed it as carefully as I'd review anything going to production. Every game imports it, and the prompt forbids working around it.

The persistence half is deliberately tiny:

class GameStore {  const GameStore(this.gameId);   // the route slug: 'snake', 'keno', 'reversi'  int best([String key = 'best']);  /// Records [score] if it beats the stored best (or undercuts it when  /// higherIsBetter is false). Returns true ONLY when a new record was set.  bool recordBest(int score, {String key = 'best', bool higherIsBetter = true});  T? pref<T>(String key);  void setPref(String key, Object value);}

Keys are namespaced game_<slug>_v1, JSON-encoded, and every read is wrapped so a disabled or full localStorage fails soft to an empty map instead of taking the page down. The presentation half is six widgets: GameButton (the gradient CTA), GameGhostButton (outlined, with an active state for mode toggles), GameStat (a score/best/time chip), GamePanel (a titled bordered container), GameBanner with a GameTone enum for win/lose/neutral, and GameHelp, a collapsible "How to play" that takes a List<(String, String)> of heading/body pairs.

That's it. That's the entire surface an agent is allowed to use for chrome.

The clause in the build prompt that did the heavy lifting was blunt: you may not introduce a new colour, a new button style, or a new persistence mechanism. If the kit doesn't have it, use the closest thing it does have.

A contract has to be code, not prose

This is the part people skip. A prose style guide gets paraphrased, drifted from, and quietly reinterpreted by every agent that reads it. A Dart file with a public API gets imported, and the compiler is the cheapest conformance test in existence. If an agent invents GameCard or reaches for ElevatedButton, the build breaks immediately and loudly, in the specific file, before I've read a line of it.

The same logic drove the registry wiring. Each agent had to add exactly two entries: metadata in builtin_games.dart (name, description, category, route, icon) and one line in games_registry.dart mapping the slug to a builder:

final Map<String, WidgetBuilder> kGameBuilders = {  'snake': (_) => const SnakeGamePage(),  '2048': (_) => const Game2048Page(),  'fifteen-puzzle': (_) => const FifteenPuzzleGamePage(),  // ...one line per game, added by that game's agent};

Two mechanical, diff-reviewable insertions. Because the metadata list also feeds the Firestore seed, the browse page, the sitemap and the related-games rail, getting those two lines right is the difference between a game that exists and a game that's merely a file.

Consistency across parallel agents isn't a style preference — it's what makes the output reviewable as a set. Thirty-two dialects means thirty-two separate review contexts. One dialect means I can scan for a pattern once and check all thirty-two for it, which is exactly what the QA pass then did.

Fan-out has a correlated-failure tax

Here's the cost nobody mentions in the "I built 50 apps in a weekend" posts. Twenty-four agents running the same base model against the same prompt don't make twenty-four independent mistakes. They make the same mistakes, because they share a prior.

I could see it in the results. Three games — tic-tac-toe, reversi and whack-a-mole — independently shipped the identical bug class: a delayed callback firing against a board that had been replaced underneath it. Nobody copied anybody. Three separate agents reached for Future.delayed to make a CPU opponent feel like it was thinking, and none of them considered what happens if the user hits "New game" during that delay.

That failure is structural. The prompt never mentioned async lifecycle, so twenty-four agents each rolled the same weighted die. Independence between agents buys you isolation of blast radius; it does not buy you diversity of judgement. Which is precisely why the review pass has to exist as its own stage, and why its findings are worth more than the individual fixes.

Adversarial QA: one reviewer per game, told to break it

The second fan-out is a reviewer agent per game, and the framing matters more than the model.

Four properties made it work:

Across 24 games it surfaced 3 high and 9 medium defects. All 12 were real. All 12 shipped fixes.

The three highs, and what they have in common

Blackjack crashed on load. The build method computed the dealer total before the deal populated the hand:

final dealerTotal =    _dealerHoleHidden ? _handTotal([_dealer.first]) : _handTotal(_dealer);

_dealer starts empty, .first throws Bad state: No element, and you get an error screen on first paint and on every new round. Guarding the empty hand is a two-line fix. Finding it required someone to ask what build() does on frame one — a question the code itself never prompts you to ask.

Fifteen-puzzle generated boards that could not be solved. This is my favourite bug of the whole project:

return (inversions + rowFromBottom).isEven;   // shippedreturn (inversions + rowFromBottom).isOdd;    // correct

On a 4-wide board, a position is solvable if and only if the inversion count plus the blank's row counted from the bottom is odd. The predicate was inverted, so the shuffler rejected every solvable permutation and accepted only impossible ones. The game compiled, passed flutter analyze, rendered perfectly, animated tiles correctly, and was unwinnable in every single session.

The sanity check that catches it costs one line of thought: the solved board [1…15, 0] has zero inversions with the blank in row 1 from the bottom, so 0 + 1 = 1 — odd. If your solvability predicate says the solved board is unsolvable, your predicate is backwards. Any generated-puzzle invariant should be validated against the known-good state before you trust it, and that check is now a line in my build prompt.

Snake froze visually while the game kept running.

bool shouldRepaint(covariant _SnakePainter old) =>    old.snake != snake || old.food != food;

The snake body is a List mutated in place — same instance every tick — so old.snake != snake is false forever. The snake only redrew when the food respawned as a new object. The game logic was completely correct; the screen was lying about it. The fix is => true, plus a comment explaining why, because that line looks like a textbook optimisation and the next reader will want to "fix" it back.

Notice what none of these are: none is a mistake about the rules of the game. One is a widget lifecycle bug, one is an inverted mathematical predicate, one is a misuse of a framework contract. Those are exactly the categories where a language model is weakest and a compiler is silent — the code is locally reasonable everywhere you look at it.

The nine mediums were the more valuable half

The highs got fixed. The mediums got generalised, and they clustered into four patterns:

Stale async callbacks (tic-tac-toe, reversi, whack-a-mole). A delayed CPU move or an auto-duck timer fires after the board was reset or the mode toggled. The fix is the same three lines every time:

final gen = ++_gen;                   // any callback in flight is now staleawait Future.delayed(_cpuThinkDelay);if (!mounted || gen != _gen) return;  // board changed — drop this move

Best-score accounting (aim-trainer fired "new best" on a tie). The kit already returns the answer; the agent recomputed it with >= instead of trusting recordBest's boolean. When your shared contract answers a question, the prompt must say use that answer — otherwise agents will helpfully re-derive it and get it subtly wrong.

End-state conditions (checkers evaluated a no-legal-moves loss without checking whose turn it was; nonogram rejected valid alternative solutions on ambiguous clue sets instead of winning on clue satisfaction; breakout restarted from zero when you cleared a level with the keyboard instead of advancing). Win conditions are where "looks right" and "is right" diverge fastest.

Input semantics (pong auto-resumed a paused game on mouse hover; dino-run spawned birds above the standing hitbox, so they were never a threat and ducking was never required). That last one isn't a crash or a wrong answer — it's a game that is less of a game than it appears. No test suite catches a dead mechanic. A reviewer handed the rules and told to be hostile did.

The move that actually changed the outcome

Batch two was eight games: Solitaire, Roulette, Video Poker, Slots, Baccarat, Keno, War and Higher or Lower. Before writing a word of it, I rewrote the build prompt using the twelve findings — not as war stories, as mechanical clauses:

Eight lines of prompt. Batch two came back with zero high-severity bugs. The QA pass still earned its keep — it found four mediums: Baccarat could be double-tapped into dealing twice and double-charging the bankroll, War recorded a best on a loss, Slots and Keno both wrote false records and bests below the starting bankroll, and Solitaire's onDoubleTap handler taxed every tap with the double-tap detection delay, which made the whole game feel sluggish. All small, all caught before launch.

Your QA findings are the training data for the next generation step. Not fine-tuning — literally paste the generalised bug list into the next build prompt. Twelve bugs bought a checklist that eliminated an entire severity class.

The generalisation step is where the value lives. "Fix the fifteen-puzzle parity check" is worth nothing to the next agent. "Verify any generated puzzle is solvable, and validate your solvability predicate against the solved state" is portable to Sudoku, to Nonogram, to a maze generator I haven't written yet. Write the rule one level up from the incident, and it compounds.

Where the agents were blind

Now the honest part, because this workflow has a hard ceiling and I hit it on the last batch.

Roulette shipped a Row with CrossAxisAlignment.stretch inside a horizontally scrolling view. In a horizontal scroll view the cross axis is unbounded, stretch demands a finite constraint to stretch to, and the layout pass throws. In debug you'd get a red error box. In a release web build, the subtree simply doesn't paint — the entire betting board rendered as blank space, on a page that otherwise looked completely fine.

Everything upstream passed it:

The only artefact that revealed it was a screenshot of the running page in a browser. Not a log, not a diff, not a review — a picture.

So the rule I now hold: an agent pipeline can verify almost everything except that the thing actually appears on screen. Every game gets loaded at a real viewport and looked at before it ships. That step is automatable — headless browser, screenshot, diff — and I'll get there. But it stays a mandatory gate rather than an optional one, because "did this render" is the single question where a false pass ships a blank page to production and every other layer of the pipeline will cheerfully tell you it's fine.

The pipeline, condensed

Steps 1 and 5 are the ones that separate this from a novelty. Step 6 is the one you don't get to skip.

Key takeaways

Multi-agent workflows aren't magic and they aren't a toy. They're an engineering discipline with the same shape as any other: define the interface, parallelise across it, verify adversarially, and feed what you learn back into the front of the pipeline. The models are good enough that the bottleneck has moved. It isn't generating the code any more — it's building the harness that tells you which of the code is wrong, and being honest about the last mile that only a human looking at a running screen can close.