Skip to content
Class 87 · Build teardown · Free, no signup

We Built 100 Levels in 55 Hours

Dragonfly Crush 2 went from a forked repository to a live, hundred-level game in 55 hours and 24 minutes. On hour 46 the live site was down and the project had no git repository at all. This is the whole build, measured — what it forked from, what broke, the four checks written in response, and the bot that played it more than seven thousand times before anyone else did.

100 levels 2 worlds 6+6 colours & specials 7,000+ simulated runs 0 audio files ~50 min read
55h 24mfirst file to live
9h 33mthe sprint
43commits, 1 every 13 min
7,711lines of .ts and .tsx
3runtime dependencies

The game reads your clock. So does this page.

Quick answer

Dragonfly Crush 2 is a match-3 game built by Robert McCullock working with an AI coding agent, forked from a finished 50-level predecessor and rebuilt to 100 tuned levels in 55 hours. The speed is not the lesson. What made it survivable was a small set of practices: a self-play bot that plays the shipped engine thousands of times, four assertions wired in front of the bundler that exit non-zero, and numbered work orders that hand context between agent sessions with a measured-or-not-verified footer. Most of those were written in the hour after the live site crashed, because that crash proved the project had no rollback point and no type check.

Key takeaways

  1. It was a fork, not a blank page. The board and the six colours are byte-identical to the first game. 22 files carried over, including the test bot.
  2. A bundler is not a type checker. esbuild strips TypeScript types without checking them, so a reference to something that does not exist shipped and took the site down.
  3. Simulation found three levels that could never be won — with nothing crashing, nothing failing a type check, and no error anywhere.
  4. Set a level's target below the median score, not at it. At the median, level 100 measured 0 wins in 20. At 0.85× the median it measured 27%.
  5. Put the tolerance in one place. One assertion reads its constant out of the component source by regular expression so the check cannot drift from the code.
  6. The dangerous document is the inherited one — true when written, still confidently formatted, describing a codebase that no longer exists.
Module 00

Play it first

Quick answer Everything below is a claim about this game. Play it for two minutes before reading any of them.

The live build at dragonfly-crush2.ddsboston.com, embedded. Click-to-load on purpose — nothing fetches until you ask for it.

That is 100 levels across two worlds, an 8×8 board, six dragonfly colours and six special tiles. No download, no account, no energy system, no timers, and no way to spend money inside it. It also ships zero audio files — every sound you just heard was synthesised in the browser at runtime.

Blue dragonfly
Blue
Green dragonfly
Green
Pink dragonfly
Pink
Purple dragonfly
Purple
Yellow dragonfly
Yellow
Orange dragonfly
Orange

The six colours, drawn in code. Yellow sits at pure lemon and orange at red-amber because the original pair blurred together at tile size on a phone.

55h 24m27 Aug 14:37 → 29 Aug 22:01
100levels, ids 1–100, no gaps
7,000+simulated playthroughs
0audio files shipped
43 COMMITS / 9h33m 28 AUG 29 AUG 30 AUG 27 Aug 14:37 First file the fork lands 27 Aug 17:47 Test harness present inherited from part one 28 Aug 08:34 Agent protocol 3 work orders, same minute 28 Aug 10:57 Device testing 10 shots, S26 Ultra 28 Aug 14:01 Deploy config Dockerfile, nginx 29 Aug 12:28 CRASH → git init site down, no repo existed 29 Aug 22:01 LIVE 43 commits in 9h33m 30 Aug 09:12 Media pack WO-19 30 Aug 17:17 Final snapshot backup + push First file to final snapshot: 75h — first file to LIVE: 55h 24m
The whole build. Reconstructed from file modification times and the git log, measured 2026-09-01. The gold band is the third day: 43 commits in 9 hours 33 minutes. Everything before the crash marker predates the repository — there was no git history until 29 August 12:28.

Why 55 hours and not 48

"About 48 hours" is how this build gets described, and it is a fair description. The measured figure from first file to live is 55 hours 24 minutes, and that is the number used everywhere on this page. Where a round number and a measurement disagree, the measurement belongs in the copy.

Module 01

What we started from

The fork Part two is not a fresh start. It is a fork of a finished 50-level game, and almost everything that made the 55 hours possible was already in the box.

The single most useful fact about this build is the one that is easiest to miss: there was a part one. Dragonfly Crush — 50 levels, working, shipped. Part two started as a copy of it. Measured across both repositories, excluding node_modules, dist, .git, Android build output and the backup tree:

Measured 2026-09-01Part onePart twoChange
.ts / .tsx files2435+11
Lines5,1207,711+2,591 (+50.6%)
Levels50100+50
Runtime dependencies23+canvas-confetti
Image files in the repo035part one drew everything in code
Board size8×88×8identical
Dragonfly colours66identical, same order

BOARD_WIDTH, BOARD_HEIGHT and the DRAGONFLY_TYPES array are the same in both files. The core of the game never changed. All 55 hours went into what surrounds it.

Interactive · Ledgerclaim 1 of 4

Was the sequel a rewrite?

Four measurements taken across both repositories. For each, decide whether the number supports the claim above it.

Part two is a ground-up rewrite

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

What arrived already working

Twenty-two files carried over unchanged in name. Three of them matter more than the rest: AutomatedTestRunner.tsx, useAIPlayer.ts and useGuardianAI.ts — the self-play test harness, the bot that drives it, and the watchdog that repairs a stuck board.

A correction to our own first pass

Researching this class, we noted that AutomatedTestRunner.tsx has a timestamp three hours and ten minutes into the project and wrote that the team had built a test bot before finishing the game. That is wrong. The harness was inherited. They did not write a bot in three hours; they forked a game that already had one — which is precisely why tuning by simulation was available on day three instead of week three.

What got pulled into its own module

Thirteen files are new. The most consequential is utils/matchEngine.ts: part one had no match engine file at all, and resolution lived inside the hooks. Extracting it is what later made the headless simulator possible, because a simulator can import a module but cannot import a React hook. utils/directorAI.ts came out of gameSystems.ts the same way. WorldMap.tsx arrived for the second world, GameIcons.tsx for the drawn icon system, and ErrorBoundary.tsx during the recovery — it is the screen the crash exposed.

The Garden — levels 1–50

Part one's whole game became part two's first half. Weather runs in-level: rain over one stretch, wind over another.

The Crystal Caverns — levels 51–100

Entirely new. Voids that nothing falls through, crystal armour that takes two hits, and a palette that overrides the time of day.

What got deleted on purpose

Two files did not make the jump, and one of them is a decision rather than a cleanup.

utils/analytics.ts — "The Blind Beacon". 1,676 bytes. navigator.sendBeacon posting to a Shopify App Proxy at /apps/dragonfly-beacon, with an ephemeral session id in sessionStorage.

Part one shipped privacy-first telemetry. Part two ships none at all. The replacement is not a stub that was never finished — the Android bridge carries an explicit no-op with the reason written into the source:

NativeBridge.ktpart two
// PER USER INSTRUCTIONS: TRACK NOTHING.
// This is a no-op to prevent JS errors if calls remain.

Hold on to that file name. It comes back in module 02, and it is the key to the strangest thing in the repository.

The mechanics, briefly

Matches are found in two passes over the board — horizontal, then vertical — with a minimum run of three. There is no dedicated L or T shape scanner; crossings are recognised afterwards by looking for a tile shared between a horizontal run and a vertical one. Which special a match produces depends on the shape, and one of the rules is not the one most people would guess.

Interactive · Huntround 1 of 3

Which special does the shape make?

Three match shapes from the shipped engine. One answer per round is the one the code actually produces.

Four in a horizontal rowone of the six specials
matchEngine.ts births a special from the run

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Art specimen sheet: six dragonfly colours rendered at 44, 64 and 120 pixels, plus lotus, lily and crystal tiles, plus a row of special and state tiles at 72 pixels
The art proof sheet the build produced. Every colour at three sizes, then the specials row: ROW, COLUMN, BLAST, SEEKER, SUPERNOVA and RAINBOW — six — followed by two states, FROZEN and SHINY, which are not specials and are the reason a quick count can come out at eight. Note the 44 px tier: that is the same floor the build's geometry assertion enforces as a minimum touch target, so the art was proofed at the size the build checks. Repository original.
Module 02

The documents that were true about a different codebase

The trap Day two produced a work-order protocol and eight audit documents. The protocol was excellent. The audit documents described features that do not exist — and the reason is not the one anybody guessed.

At 08:34 on the second day, three work orders and an agent protocol were written in the same minute. At 10:57 there are ten screenshots taken on a real Samsung S26 Ultra — native camera-roll filenames, genuine hands-on testing. At 11:11, eight documents appear in a folder called build plans with the identical modification time: one batch, not written incrementally across the nine "surgical sprints" they narrate.

Dragonfly Crush 2 running on a Samsung S26 Ultra during day-two testing
Day two, 10:57 — on the phone.
A second day-two test screenshot from the same device session
Same session. Real device, not an emulator.

One of those eight documents, MASTER_AUDIT.md, prices the project at 143.5 hours against a rate card, adds a competitor cost comparison and a "Privacy Score: 100/100". The next day a second work order audited it against the source and rejected eight of its claims:

_ops/WO-03_CODEBASE_AUDIT.mdline 11
The `build plans/` reports certify work that does not exist in the
code. Three of the eight PASS verdicts are for features with no
implementation. This is not drift or staleness — the reports describe
mechanisms in specific detail that was never written.

That is a fair reading of the evidence available to it. It is also, we now know, the wrong conclusion. Work through the claims yourself first.

Interactive · Decidercase 1 of 6

Is the audit document telling the truth?

Six claims from MASTER_AUDIT.md, quoted. For each one: is it true of the codebase the document is sitting in?

utils/analytics.ts — The Blind Beacon. Privacy-first telemetry using navigator.sendBeacon to track level starts and wins without cookies.

part two:  no utils/analytics.ts, no sendBeacon anywhere

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Then we connected the first game

Every claim that failed is true of part one.

MASTER_AUDIT.md claimsTrue of part two?True of part one?
utils/analytics.ts, "Blind Beacon" via sendBeaconno — file absentyes — 1,676 B, posts to /apps/dragonfly-beacon
Strict Mode in the entry pointno — 0 matchesyes — 2 matches
50 levelsno — 100yes — exactly 50
Entirely asset-lessno — 35 image filesyes — zero image files
27 filesno — 35close — 25
~4,250 linesno — 7,711closer — 5,324

The document was never fabricated

It is part one's audit, carried across the fork and never revised. Four of its claims are exactly true of the codebase it was actually written for; two are close. Forking a project forks its documentation, and nothing marks the copy stale.

This matters more than a correction to a filing error, because it changes what you should be watching for. The dangerous document in an AI-assisted project is usually not the invented one. It is the inherited one: accurate when it was written, still confidently formatted, describing a codebase that no longer exists. A reader checking it against part two finds it false. A reader checking it against part one finds it accurate. Only a reader holding both sees what happened.

And the audit that got it wrong could not have got it right. Nobody gave it the other repository. Its method could not see outside the directory it was pointed at — which is exactly the failure it had already caught itself making once before.

The document that corrects itself

The same file contains two labelled corrections. The first is the most useful paragraph in the entire project:

_ops/WO-03_CODEBASE_AUDIT.mdCORRECTION 1
I wrongly called the Shiny Mastery claim fabricated. My first pass
reported no shiny / iridescent / 5,000-tile milestone in the source.
That was a measurement error on my part: the grep was piped through
head -12 and the output was truncated before the BestiaryMenu.tsx
matches. Re-run without truncation it returns 19 matches. The prior
agent was right and I was wrong on this one; the method that produced
my wrong answer was a truncated grep whose truncation I did not notice.

An agent that found a document mostly wrong, then found that one of the claims it had called wrong was right, said so in the same file, credited the previous agent, and named the exact mechanism that produced its own error. head -12.

The second correction retracts its own earlier claim about where the site was deployed: it had cited a config file and a response header, and it writes that "the presence of a config file is not evidence it is in use". The decisive evidence turned out to be the format of the ETag on the live response. Same failure shape both times: a method that could not see far enough, named plainly once it was found.

What a work order actually contains

The difference between the eight documents that were wrong and the nine that were right is not the model that produced them. It is what the request asked for and what shape it demanded back. Click through a real one.

Interactive · Anatomyregion

The anatomy of a work order

A real work order from this repository, trimmed. Click any highlighted line to see what that section is load-bearing for.

# WO-06 — Level progression and retuningThe simulator imports the production modules unmodified.The board is 8x8 for every level and does not vary.Pity systems are dormant during simulation runs.A1  Re-simulate all 100 levels at n=20 and report the win rate for each.A2  Repair any level measuring below 15 percent and re-measure it.A3  Report the four worst levels with before and after values.

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Nine of these exist as standalone documents. Ten more work-order numbers appear only as commit messages, and two numbers are unexplained gaps. The protocol was not applied perfectly. It was applied well enough that when something went badly wrong, the recovery had a shape to follow.

Module 03

The crash

Hour 46 The live site is serving an error screen, the bug is one missing import, and the project has no version control at all.

On the morning of the third day the game was down. Players got the error boundary. The cause, recorded in the recovery work order: a component rendered an AnimatedBackground element without importing it. That throws a ReferenceError the moment the component mounts.

The build had passed. It passed because at that point the build was vite build and nothing else, and:

esbuild strips TypeScript types without checking them

Vite's transform pipeline removes type annotations to produce JavaScript. It does not type-check. A reference to a symbol that does not exist is a type error, not a syntax error, so the bundler emitted a perfectly valid bundle containing a call to nothing. Three more "calls something that does not exist" bugs were found in the same sweep.

Then the second sentence of the recovery work order, which is the worse half:

_ops/WO-01_RECOVERY_AND_AUDIT.mdline 17
There is NO git repository in this project — zero commits, no .git
directory. You have no rollback point.

Forty-six hours of work, a live site that is broken, and nothing to roll back to. Phase 0 of the recovery is therefore not the fix. It is: copy the entire project except node_modules and dist to a sibling folder, run git init, make one commit. That backup still sits in the repository at 652 files, and the count matches what the recovery report claims exactly.

Every commit this project has ever had begins after this moment. The git history is 47 commits across 29 hours, and the first one is git's literal initial commit. A casual audit that counted commits and read the first commit date would conclude the project was 29 hours old. File timestamps and on-device screenshots put real work two days earlier. The short history is a record of when version control was adopted, not of when work started.

The four gates

The fix for the crash was one import. The response to the crash was four checks, wired in front of the bundler, each of which exits non-zero:

package.jsonthe build script
"build": "tsc --noEmit
          && node scripts/assert-map-geometry.mjs
          && node scripts/assert-game-layout.mjs
          && node scripts/smoke-test.mjs
          && vite build"
npm run build — every gate exits non-zero on failure the missing import that took the site down enters here and stops at gate 1 GATE 1 tsc --noEmit type check catches the missing import GATE 2 assert-map-geometry 7 viewports overlap, overflow, 44px target GATE 3 assert-game-layout 8 viewports no-scroll arithmetic GATE 4 smoke-test jsdom mount error boundary, empty root, console GATE 5 vite build bundle emits dist/ esbuild strips types without checking them — the bundler alone never saw it
The pipeline as it stands today. The bundler is last, not first. The defect that took the site down enters at the left and stops at gate 1 — which is the entire reason gate 1 exists.

What each one actually checks

assert-map-geometry.mjs walks seven device viewports from 320×480 to 1440×900 and asserts that every world-map node stays inside its container including its star strip, that no two nodes overlap, that adjacent star strips do not collide, and that every node meets a 44 px minimum touch target. On any failure it logs the viewport and the reason and calls process.exit(1).

assert-game-layout.mjs re-derives the no-scroll board arithmetic across eight viewports and fails if the content would scroll, if the board would fall below a 260 px "fat-finger" floor, or if it would exceed the viewport width. And it does one thing worth stealing outright:

scripts/assert-game-layout.mjsline 22
// GAME_CHROME_PX must equal the constant in GameBoard.tsx,
// so the two cannot drift.
const CHROME = +src.match(/export const GAME_CHROME_PX\s*=\s*(\d+)/)[1];

The assertion reads its own tolerance out of the component source by regular expression instead of hardcoding it. A plain Node script cannot import from a TSX module without a build step, so rather than keep a second copy of the number — which someone eventually updates in only one place — it parses the first copy. Cheap, low-tech, and exactly right for a codebase where an agent may edit either file without remembering the other exists.

smoke-test.mjs bundles the real entry point, mounts it in jsdom with stubbed AudioContext, canvas and matchMedia, and fails if the error boundary text appears, if the root element has no children, or if anything logged an error while mounting. Two details raise it above a health check. Its header records that it was verified to discriminate — run against the broken source it reports failure, run against the fixed source it reports success. A test never observed failing is not known to work. And it keeps both error-boundary strings, the current one and the one it replaced, with the reason in a comment: the heading changed in a later work order, and the old check would have silently passed on a real boundary hit.

Interactive · Gate Runnercandidate 1 of 5

Which gate stops it?

Five changes entering the build pipeline. Pick the gate you think refuses each one, then see how far it actually gets.

A component renders an AnimatedBackground element without importing it

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

The general form

Every one of these gates encodes a specific failure that already happened once. That is the only reliable way to choose them. A checklist assembled from good intentions gets deleted; a check that has visibly caught the thing it was written for survives.

Module 04

The sprint

Day three Forty-three commits in nine hours and thirty-three minutes. One every 13.3 minutes, sustained, with four gates in front of every one of them.

The recovery finished at 12:30 on the third day. What follows is the whole of that day, taken from the commit log verbatim. It is worth reading as a sequence rather than a list, because the ordering is the argument: infrastructure first, then content, then polish, then a release audit, then a full re-measurement.

TimeCommitSubject, verbatim
12:2819f3db2Recovery: fix live-site crash, add typecheck + mount-test build gates
13:17fc4fcbeRestore Tailwind: index.css had no @tailwind directives
13:229006534_ops: full codebase audit (WO-03)
13:341eac912Restore original horizontal world map from the Aug-28 known-good build
14:107899127Fix targeted boosters (stale closure) + HUD label; WO-05 playtest report
14:2392d1bccSpecial-tile visuals for all five types + cascade performance pass
14:38133e5fbNo-scroll game screen: compact HUD, orientation-aware layout, layout gate
15:08f3f0f89Level progression: repair unwinnable levels, retune all 100 to a measured curve
15:383db74c7WO-08: full tile-art rebuild to reference anatomy
15:52edf4870WO-08 rev2: fix sprite clipping, add gloss pass, geometry-assert the art
16:086209fd0WO-09 P1: drawn icon system replaces every chrome emoji
16:1127f1620WO-09 P2: unified buttons, board pulled up, animated loading, error restyle
16:145986182WO-09 P3: modal interiors — lose card, win rays, unified CTAs, copy fix
16:1868e0e8dWO-09 P4: consistency sweep complete
16:54455d269WO-10: full sound identity — layered SFX + generative chrono music
17:08d3bd462WO-11: World 2 complete — voids real, crystal armor real, cavern theming
17:553513e0bRELEASE AUDIT: make every Grimoire promise true in the engine
18:25436cea5WO-13: house ads rebuilt from the live store, Support hub, thank-you cards
18:569b774f7WO-15: full 2026 SEO/GEO/AEO stack
20:45a7f2c85Full-suite playtest: all 100 levels, 2000+ simulated games; 4 walls fixed
21:223be144dWO-17: save export/import round-trip no longer destroys progress
22:0153938cfWO-18: full interactive surface sweep + two small fixes

Note 16:08 to 16:18. A complete UI overhaul — icon system, buttons, modal interiors, consistency sweep — in four gated commits over ten minutes. That is only possible because each one passed a type check and two geometry assertions before it landed. Speed at this cadence is not carelessness; it is what carelessness gets replaced with once the checks are automatic.

The art was asserted, not eyeballed

Read that 15:52 subject again: "geometry-assert the art". The tile rebuild did not finish with someone looking at it and nodding. Sprite geometry got a numeric check, the same way the map layout did. Here is the proof sheet the pass produced:

Icon proof sheet: nineteen drawn icons rendered at 24, 48 and 96 pixels, each labelled, including a greyed unearned star variant
Nineteen icons, three sizes, one sheet. This replaced every emoji in the interface in a single ten-minute pass. Rendering the whole set at 24, 48 and 96 px at once is what makes a legibility problem obvious — an icon that works at 96 and dies at 24 is visible here and invisible in the app. Repository original.
A three by three board of dragonfly tiles filling their cells after the fill fix
The tile-fill fix. Commit subject: "Tiles filled: dragonflies now dominate their cells." A proof image attached to the work order is a cheap, durable record — it survives in the repository long after the conversation that produced it is gone.

A soundtrack with no audio files

At 16:54, the entire sound identity. Measured across the repository: zero audio files of any extension. Instead, utils/audio.ts is 15,499 bytes of Web Audio synthesis — oscillators, noise buffers, gain envelopes, a filter and a compressor — producing thirteen named sound effects plus a generative soundtrack whose chord progression changes with the time of day on your own clock.

The part worth copying is that it is instrumented for verification without ears. The module exposes a handle on window and a measurePeak() function, so an agent with no speakers can confirm signal is actually being produced rather than reporting that the code looks correct.

The trade, quantified

15,499 bytes of synthesis code replaces the entire audio asset budget. For comparison the visual art budget in the same repository is 124,614 bytes. The game spends roughly eight times more on line art than a modest compressed sound set would have cost — and still ships no audio, because the code was the cheaper half.

The Crystal Caverns, world two, with crystal armour tiles on the board
World 2 — crystal armour takes two hits.
Rain weather falling over a level in play
Weather, in-level only.
The world map rendered in its night time-of-day phase
The map at night — four phases, read from your clock.

One commit in this list made a promise it did not keep

The 14:23 performance pass fixed real cascade jank — moving tile motion from top/left to transform so it runs on the compositor, and restoring memoisation across all 64 tiles. Its own message promises a live before-and-after frame comparison against a measured baseline of 300.2 ms worst frame. That after-measurement was never taken. The "before" is genuinely measured. The fix is almost certainly real. It is still, strictly, unverified — and it is listed as such in module 06 rather than quietly counted as a win.

Module 05

Testing it seven thousand times

Simulation A bot played every level twenty times, using the same engine that ships. It found three levels that could never be won — with nothing crashing and no error anywhere.

scripts/simulate-levels.mjs is a headless harness: 100 levels, 20 runs each by default, driven by a one-ply greedy bot that enumerates every legal swap and plays the best one by an objective-weighted score. Its own header describes the bot as "roughly a competent human, not an optimizer".

The design decision that makes it worth anything is in its import list:

scripts/simulate-levels.mjslines 24–28
import { LEVELS, BOARD_WIDTH, BOARD_HEIGHT } from '../constants.ts';
import { applyGravity, createTile, SCORING }   from '../utils/gameSystems.ts';
import { resolveMatches }                      from '../utils/matchEngine.ts';
import { fillEmptyTiles }                      from '../utils/directorAI.ts';
import { findMatchGroups, findPossibleMove }   from '../utils/boardLogic.ts';

It imports the modules that ship. Not a model of them, not a simplified reimplementation — the same files bundled for players. Only the outer game loop is rewritten, because the real one is a React hook and a Node script cannot run those. This is why the match engine had to be extracted into its own module first: a simulator can import a module, but it cannot import a hook. A balance measurement taken this way cannot drift from what a player meets, because there is only one implementation to drift from.

Boosters are never modelled. The bot has none and uses none, which is precisely what makes "every level is winnable" a strong claim rather than a soft one — it is verified beatable with zero boosters and zero currency spent.

What twenty attempts found that reading could not

Three clear-blocker levels measured 0% win rate and 0% progress. The match engine was not counting thawed amber tiles toward the objective, so the objective could never complete. Nothing crashed. Nothing failed a type check. No error appeared in any console. The levels were simply impossible, and they would have shipped that way.

Separately, seven of the eleven clear-blocker levels had board layouts containing fewer blocker tiles than the objective demanded — some containing none at all. And level 84's layout held exactly twelve ambers while its objective required all twelve, leaving no margin for a single unlucky cascade.

0 20 40 60 80 100 120 LEVEL 51 — CRYSTAL CAVERNS THE GARDEN 1 20 31 51 71 90 100 Score · 40 ClearColor · 33 CollectItem · 16 ClearBlocker · 11 Move budget per level — all 100, measured from constants.ts
Every level's move budget, parsed from constants.ts. The sawtooth is deliberate: budgets climb across each ten-level arc to a boss, then reset at the next arc and hard at the world boundary. Objective mix, counted from the same parse: Score 40, ClearColor 33, CollectItem 16, ClearBlocker 11. The clear-blocker levels are the outliers sitting low — short skill puzzles rather than long grinds.

A correction we made while building this figure

Our own research notes described the clear-blocker levels as a "flat 12–22 move band". Parsing all 100 levels rather than a sample shows the real set of blocker budgets is 12, 14, 16, 18, 20, 22, 34, 36 and 40. The lily-pad levels cluster at 12–22; the amber levels in world two sit at 34–40. The generalisation came from sampling. The figure above is drawn from the full parse, which is why it disagrees with the note that preceded it.

The result that is worth the whole module

Level 100 is the finale: 120 moves, a score objective. The obvious way to set its target is to measure what a good player scores and use that. The repository records what happened:

L100 at median (590k) measured 0/20; at 0.85× median (500k) it measured 27%.

At the median, nobody wins. Not "about half", which is what intuition predicts — zero out of twenty. On a long level, cascade luck averages out across 120 moves, and the distribution of achievable scores has a much thinner winning tail than losing tail. The median is not the middle of the outcomes you care about.

Interactive · Ledgerclaim 1 of 4

Four retunes, measured before and after

Each row is a real target change from the level table, with the win rate that justified it. Decide whether the measurement supports the claim above it.

A level's target should sit at the score a competent player actually reaches

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

How many runs, honestly

The published figure is "roughly 7,000 simulated playthroughs". Two totals are documented in the repository: a tuning work order that states "~6,000 simulated games total across baseline and three tuning iterations", and a later full sweep whose commit message says "all 100 levels, 2000+ simulated games". One full sweep at the default settings is 100 × 20 = 2,000 runs.

Those two documented totals sum to more than eight thousand. The published figure is conservative, not inflated. What is genuinely not verified is the exact count, because the script prints to standard output and never writes a run counter to a file — so no single artifact adds up every invocation. That is a logging gap, and it is worth fixing before it becomes an argument.

We got this wrong first, and it is instructive

The first research pass on this class labelled the 7,000 figure unverified and likely inflated, and led with it as the headline problem. That was wrong. The addition — 6,000 plus 2,000 — was sitting in the same report that filed the verdict, and two successive passes failed to perform it. The method that produced the wrong answer: treating "no single document states the total" as equivalent to "the total is unsupported".

The axis nobody simulated

Thousands of runs retuned move budgets and score targets. The star rating — whether a level awards one, two or three — was never touched by any of it. It is a single global formula applied identically to a 12-move puzzle and a 120-move finale:

components/GameBoard.tsxlines 199–204
let stars = 1;
const performanceRatio = level.moves > 0 ? finalMovesLeft / level.moves : 0;
if (performanceRatio >= 0.4) stars = 3;
else if (performanceRatio >= 0.15) stars = 2;

On a 12-move level, three stars means finishing with 5 moves spare. On a 120-move level it means finishing with 48. Whether those are comparable achievements was never measured, because nobody built a harness that asks. Simulation measures what you built the harness to measure, and the un-instrumented axis right beside it looks just as finished.

The win screen showing a star rating after completing a level
The screen that ratio produces. Measured everywhere except here.
Module 06

What we would do differently

Still open Everything unresolved, stated plainly, including the things this class got wrong while it was being written.

Still open in the repository

  • The stale audit still has no warning on it. MASTER_AUDIT.md sits in the tree describing part one, and the retraction lives in a different file one directory away. A single header line on the document itself would stop the next reader repeating the whole investigation.
  • The "(Debug)" test suite ships to players. The button that launches the automated test runner sits in the ordinary settings modal, passed unconditionally. A repository-wide search for import.meta.env, NODE_ENV and __DEV__ returns zero matches anywhere. "(Debug)" is a string in a label, not a build flag.
  • There is no CI. The four gates are real and they run, but they run on whoever types the build command. Nothing runs the level-completion bot before a deploy, and the smoke test is a mount test rather than a gameplay test.
  • A performance number was promised and never taken. The cascade fix has a measured "before" of 300.2 ms worst frame and no "after". The fix is sound by inspection. It is not verified by measurement, and those are different things.
  • Two deploy configurations still disagree. An App Engine config sits beside a Cloud Run deploy script. The repository's own audit records that the dead config "cost a full agent session" — one that tried the wrong deploy command, hit a disabled API on the wrong project, and nearly enabled it.
  • Particle counts are uncapped. The effects canvas parks its animation loop when idle and when the tab is hidden, which is good, but nothing bounds the total particle population across simultaneous effects. It is on the project's own backlog.

Accessibility, honestly

The six matchable colours share one dragonfly shape and differ only by hue. There is no shape or symbol fallback. The three blocker tiles — lotus, lily, crystal — are shape-differentiated, which is the trap: a quick check for "does this game use shape encoding?" finds yes and ticks the wrong box, because the pieces actually at risk are the six a player must parse fastest under time pressure. There is a documented fix in the source pushing yellow and orange further apart in hue, which helps someone with mild colour vision deficiency and not at all someone with none. There is no keyboard path to the board, and prefers-reduced-motion covers five animation classes but not the tile motion, the particles or the ambient background.

Three corrections this class made to itself

A class about verification that hides its own misses is worth less than one that shows them.

What we said firstWhat is trueThe method that produced the error
The 7,000 simulated runs figure is unverified and probably inflated It is conservative — the two documented totals sum past 8,000 Treated "no single document states the total" as "the total is unsupported", without doing the addition that was sitting in the report being audited
They wrote a self-play test bot three hours into the project The harness was inherited from part one along with 21 other files Read a file timestamp as a creation date without checking whether the file predated the fork
The clear-blocker levels sit in a flat 12–22 move band They run 12–40; only the lily-pad ones cluster low Generalised from a sample of the level table instead of parsing all 100 rows

The first of those was caught by the project owner, not by the research. The second and third were caught by connecting a second repository and by writing a script that asserts its own input — the level parser refused to run when it found 99 levels instead of 100, which is how we learned it was silently skipping the one level whose retune comment sits between two fields it was matching on.

What we would put in place before starting again

Not a checklist of good intentions — the specific things whose absence cost this build real time.

Interactive · Forgelive

Write a work order you can actually hand over

Fill these in and download the result. The shape is the one this project used: settled first, asks numbered, stop conditions explicit, and a footer that separates what was measured from what was not.


Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

The five that actually mattered

Run git init before the first line of work, not after the first outage. Type-check separately from bundling, because a bundler that strips types will ship a call to nothing. Put geometry in the build and let it exit non-zero, because layout is the thing humans are worst at checking by eye. Prove each new check can fail against the broken source before you trust it green. Simulate against the code that ships, so a balance measurement and a player's experience cannot diverge.

Bottom line

A hundred tuned levels in fifty-five hours is not a story about typing quickly. Almost everything that made the pace possible was in place before the pace happened: a game that already worked, a test harness that came with it, and an engine module that could be imported by something other than a browser. Everything that made it survivable was written in one hour on the third day, in response to a live outage that proved the project had no type check and no rollback point. The gates came from the crash. The work orders came from needing to hand context between sessions without losing it. And the most misleading document in the repository was not wrong when it was written — it was written about a different game, and copied.

Questions

Frequently asked

How long did Dragonfly Crush 2 actually take to build?

Fifty-five hours and twenty-four minutes from the first file written to the game being live, measured from file modification times and the git log. The first file has a timestamp of 2026-08-27 at 14:37 and the last sprint commit is 2026-08-29 at 22:01. Inside that window the concentrated build was nine hours and thirty-three minutes on the third day, during which forty-three commits landed, an average of one every thirteen minutes. The colloquial description of about forty-eight hours is fair, but the measured figure is fifty-five hours and twenty-four minutes and that is the number this class uses.

Was the game built from scratch?

No, and this matters more than any other fact about the build. Part two is a fork of Dragonfly Crush, a working fifty-level game. The board size and the six dragonfly colours are byte-identical across both repositories. Twenty-two files carried over unchanged in name, including the self-play test harness, the AI player hook and the guardian watchdog. Thirteen files are new, including the match engine, which part one did not have as a separate module. Two files were deliberately deleted. Measured: twenty-four TypeScript files and 5,120 lines in part one, thirty-five files and 7,711 lines in part two.

What does it mean to test a game 7,000 times?

A headless script drives the real production engine with a one-ply greedy bot that enumerates every legal swap and plays the best one, twenty runs per level by default across one hundred levels. It imports the same match engine, board logic, director and game systems files that ship to players, so a balance measurement cannot drift from what a player experiences. Two documented totals exist in the repository: roughly six thousand simulated games across a baseline and three tuning passes, plus a later full sweep of two thousand or more. The published figure of roughly seven thousand is therefore conservative against the project's own record.

What did simulation find that reading the code could not?

Three levels that could never be won. The match engine was not counting thawed amber tiles toward the clear-blocker objective, so those levels measured zero percent win rate and zero percent progress. Nothing crashed, nothing failed a type check, and no error appeared anywhere. Separately, seven of eleven clear-blocker levels had board layouts containing fewer blocker tiles than the objective demanded, and one level's layout held exactly twelve ambers while its objective required all twelve. None of these are visible by reading the source; all of them are obvious after twenty simulated attempts.

Why should a level's score target be below the measured median?

Because on a long level, cascade luck averages out and the winning tail of the achievable-score distribution is much thinner than the losing tail. The repository records the experiment directly: level one hundred with its target set at the measured median of 590,000 points measured zero wins in twenty attempts, and the same level at 0.85 times the median, 500,000 points, measured a twenty-seven percent win rate. Setting a target at the average score produces almost no wins rather than the roughly half that intuition predicts.

What caused the live site to go down?

A missing import. A component rendered an animated background element without importing it, which throws a ReferenceError at runtime. The bundler did not catch it because esbuild strips TypeScript types without type-checking them, so the build passed and the broken bundle shipped. Three further calls to things that did not exist were found in the same pass. Compounding it, the project had no git repository at all at that moment, zero commits, so there was no rollback point. The recovery created a full backup copy of 652 files, ran git init, and added four checks to the build command.

What are the four build gates and do they actually fail a build?

They run in front of the bundler and every one of them exits with a failure code. A TypeScript no-emit type check catches the class of error that took the site down. A map geometry assertion checks, across seven device viewports, that no world-map node overflows its container, that no two nodes overlap, that adjacent star strips do not collide, and that every node meets a forty-four pixel minimum touch target. A game layout assertion re-derives the no-scroll board arithmetic across eight viewports and reads its tolerance constant out of the component source by regular expression so the check and the implementation cannot drift. A smoke test bundles the real entry point, mounts it in a simulated DOM, and fails if the error boundary appears, if the root is empty, or if anything logged an error while mounting.

Why did an audit document in the repository describe features that do not exist?

Because it was written about the first game and carried across the fork without being revised. It claims a privacy telemetry module, React Strict Mode, fifty levels and a completely asset-less build. All four are false of the sequel and all four are true of the original: the telemetry file exists there at 1,676 bytes, Strict Mode appears twice in its entry point, it has exactly fifty levels, and it contains zero image files. An internal audit checked the document against the sequel, correctly found the claims false, and reasonably concluded they had been fabricated. Holding both repositories at once shows the real answer, which is that forking a project also forks its documentation and nothing marks the copy stale.

Does Dragonfly Crush 2 use an AI model to run the game?

No. The systems named Director AI and Guardian AI are hand-written rule-based heuristics operating on plain numbers. The Director adjusts which colour spawns into empty cells based on a failure count, a computed panic ratio and a clutch flag, and it is disclosed to players by name inside the game. The Guardian is a watchdog that detects board holes, unresolved matches and deadlocks and repairs them. The shipped bundle contains no application network call of any kind. The project's configuration still references an AI model API key inherited from the template it was scaffolded on, but no such library is installed and nothing imports it.

Is Dragonfly Crush 2 free to play, and is this class free?

Both are free. The game runs in a browser with no download, no account, no energy system and no way to spend money inside it. This masterclass has no signup, no email capture, no paywall and no certificate. Every figure quoted on this page is measured from the two game repositories and cited to a file and line, including the mistakes, and including two corrections to claims made earlier in this project's own research.

Keep going

The whole Academy is free

No signup, no email capture, no paywall, no certificate. Every class is measured from a real repository and cites its sources, including the mistakes.

Browse all classes Play the game