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
- 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.
- 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.
- Simulation found three levels that could never be won — with nothing crashing, nothing failing a type check, and no error anywhere.
- 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%.
- 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.
- The dangerous document is the inherited one — true when written, still confidently formatted, describing a codebase that no longer exists.
Play it first
Quick answer Everything below is a claim about this game. Play it for two minutes before reading any of them.
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.
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.
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.
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-01 | Part one | Part two | Change |
|---|---|---|---|
| .ts / .tsx files | 24 | 35 | +11 |
| Lines | 5,120 | 7,711 | +2,591 (+50.6%) |
| Levels | 50 | 100 | +50 |
| Runtime dependencies | 2 | 3 | +canvas-confetti |
| Image files in the repo | 0 | 35 | part one drew everything in code |
| Board size | 8×8 | 8×8 | identical |
| Dragonfly colours | 6 | 6 | identical, 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.
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
measured delta +11 files +45.8%
Part two is a ground-up rewrite
measured delta +2591 lines +50.6%
The sequel doubled the game
measured delta +50 levels +100.0%
The sequel took on heavy dependencies
measured delta +1 packages +50.0%
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.sendBeaconposting to a Shopify App Proxy at/apps/dragonfly-beacon, with an ephemeral session id insessionStorage.
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:
// 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.
Which special does the shape make?
Three match shapes from the shipped engine. One answer per round is the one the code actually produces.
matchEngine.ts births a special from the run
The orientation is not the one you would guess.
the same birth call, one branch up
Five is its own case, and six is a different one again.
a separate detector running alongside the line scanner
Squares are not found by the line passes at all.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
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.
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:
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.
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
hooks/useGuardianAI.ts — The Watchdog. A background process that monitors board integrity, detecting deadlocks or holes and auto-repairing the game state without user intervention.
GameBoard.tsx:101-110 calls it with no isTestMode guard
index.tsx mounts the application in Strict Mode.
grep -c StrictMode index.tsx part two: 0
constants.ts contains the complete configuration for 50 Levels.
levels counted from constants.ts part two: 100
components/AutomatedTestRunner.tsx — The QA Department. A UI that runs the AI Player through the levels to verify win conditions.
AutomatedTestRunner.tsx:39-72
The application is entirely asset-less — every dragonfly is drawn with code rather than shipped as an image file.
image files in the repo part two: 35
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 claims | True of part two? | True of part one? |
|---|---|---|
| utils/analytics.ts, "Blind Beacon" via sendBeacon | no — file absent | yes — 1,676 B, posts to /apps/dragonfly-beacon |
| Strict Mode in the entry point | no — 0 matches | yes — 2 matches |
| 50 levels | no — 100 | yes — exactly 50 |
| Entirely asset-less | no — 35 image files | yes — zero image files |
| 27 files | no — 35 | close — 25 |
| ~4,250 lines | no — 7,711 | closer — 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:
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.
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.
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:
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:
"build": "tsc --noEmit
&& node scripts/assert-map-geometry.mjs
&& node scripts/assert-game-layout.mjs
&& node scripts/smoke-test.mjs
&& vite build"
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:
// 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.
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
- tsc --noEmittype-checks every file without emitting anythingREFUSED
- assert-map-geometry.mjsworld-map nodes across 7 viewports: overflow, overlap, star-strip collision, 44px touch targetnever reached
- assert-game-layout.mjsre-derives the no-scroll board arithmetic across 8 viewportsnever reached
- smoke-test.mjsbundles the real entry point and mounts it in jsdomnever reached
- vite buildemits the production bundlenever reached
A world-map node's star strip overlaps its neighbour at 320px wide
- tsc --noEmittype-checks every file without emitting anythingpass
- assert-map-geometry.mjsworld-map nodes across 7 viewports: overflow, overlap, star-strip collision, 44px touch targetREFUSED
- assert-game-layout.mjsre-derives the no-scroll board arithmetic across 8 viewportsnever reached
- smoke-test.mjsbundles the real entry point and mounts it in jsdomnever reached
- vite buildemits the production bundlenever reached
A taller HUD pushes the board 14px past the viewport height on a Pixel
- tsc --noEmittype-checks every file without emitting anythingpass
- assert-map-geometry.mjsworld-map nodes across 7 viewports: overflow, overlap, star-strip collision, 44px touch targetpass
- assert-game-layout.mjsre-derives the no-scroll board arithmetic across 8 viewportsREFUSED
- smoke-test.mjsbundles the real entry point and mounts it in jsdomnever reached
- vite buildemits the production bundlenever reached
A context provider throws during mount, so React falls through to the ErrorBoundary
- tsc --noEmittype-checks every file without emitting anythingpass
- assert-map-geometry.mjsworld-map nodes across 7 viewports: overflow, overlap, star-strip collision, 44px touch targetpass
- assert-game-layout.mjsre-derives the no-scroll board arithmetic across 8 viewportspass
- smoke-test.mjsbundles the real entry point and mounts it in jsdomREFUSED
- vite buildemits the production bundlenever reached
A copy change to one level's description string
- tsc --noEmittype-checks every file without emitting anythingpass
- assert-map-geometry.mjsworld-map nodes across 7 viewports: overflow, overlap, star-strip collision, 44px touch targetpass
- assert-game-layout.mjsre-derives the no-scroll board arithmetic across 8 viewportspass
- smoke-test.mjsbundles the real entry point and mounts it in jsdompass
- vite buildemits the production bundlepass
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.
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.
| Time | Commit | Subject, verbatim |
|---|---|---|
| 12:28 | 19f3db2 | Recovery: fix live-site crash, add typecheck + mount-test build gates |
| 13:17 | fc4fcbe | Restore Tailwind: index.css had no @tailwind directives |
| 13:22 | 9006534 | _ops: full codebase audit (WO-03) |
| 13:34 | 1eac912 | Restore original horizontal world map from the Aug-28 known-good build |
| 14:10 | 7899127 | Fix targeted boosters (stale closure) + HUD label; WO-05 playtest report |
| 14:23 | 92d1bcc | Special-tile visuals for all five types + cascade performance pass |
| 14:38 | 133e5fb | No-scroll game screen: compact HUD, orientation-aware layout, layout gate |
| 15:08 | f3f0f89 | Level progression: repair unwinnable levels, retune all 100 to a measured curve |
| 15:38 | 3db74c7 | WO-08: full tile-art rebuild to reference anatomy |
| 15:52 | edf4870 | WO-08 rev2: fix sprite clipping, add gloss pass, geometry-assert the art |
| 16:08 | 6209fd0 | WO-09 P1: drawn icon system replaces every chrome emoji |
| 16:11 | 27f1620 | WO-09 P2: unified buttons, board pulled up, animated loading, error restyle |
| 16:14 | 5986182 | WO-09 P3: modal interiors — lose card, win rays, unified CTAs, copy fix |
| 16:18 | 68e0e8d | WO-09 P4: consistency sweep complete |
| 16:54 | 455d269 | WO-10: full sound identity — layered SFX + generative chrono music |
| 17:08 | d3bd462 | WO-11: World 2 complete — voids real, crystal armor real, cavern theming |
| 17:55 | 3513e0b | RELEASE AUDIT: make every Grimoire promise true in the engine |
| 18:25 | 436cea5 | WO-13: house ads rebuilt from the live store, Support hub, thank-you cards |
| 18:56 | 9b774f7 | WO-15: full 2026 SEO/GEO/AEO stack |
| 20:45 | a7f2c85 | Full-suite playtest: all 100 levels, 2000+ simulated games; 4 walls fixed |
| 21:22 | 3be144d | WO-17: save export/import round-trip no longer destroys progress |
| 22:01 | 53938cf | WO-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:
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.
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.
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:
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.
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.
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
measured delta -90000 points -15.3%
A boss level should be tuned as a checkpoint, not as a finale
measured delta -12000 points -7.5%
An objective that needs every blocker on the board is fine as long as the board has enough
measured delta -3 amber tiles -25.0%
Small target cuts produce correspondingly small changes in win rate
measured delta -1 amber tiles -12.5%
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:
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.
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.mdsits 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_ENVand__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 first | What is true | The 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.
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.
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