{"slug": "spec-at-lunch-game-by-eight", "title": "Spec at Lunch, Game by Eight", "summary": "A developer used a coding agent to build a cross-platform casual pond game in a single afternoon wrapped around a normal workday, starting with a 40-minute, 15-section spec written during a lunch break. The spec's core physics decision applies each ripple's push as a single impulse per (ring, petal) pair at the step the ring front crosses the petal's centre, using two squared radii and a hitSet to keep the push deterministic and frame-timing independent. The build targeted web, phones, and desktops from one codebase, with six ordered milestones, a tiered difficulty table, and an explicit out-of-scope list for v1.", "body_md": "I've wanted to build a casual game for a while. Not a demo. Not a \"look, a sprite moves\" tech test. A game with rules, a difficulty curve, a daily mode, an unlock, and a title screen that doesn't embarrass me. Cross-platform: web, phones, desktops. One codebase.\n\nFriday I decided to stop wanting and just do it. The constraint was the interesting part: one afternoon, wrapped around a normal workday, with a coding agent doing the typing. Here's how that went, with timestamps, because \"I built a game in an afternoon\" is a claim that deserves receipts.\n\n## The spec was the work\n\nHere's the thing nobody wants to hear about agent coding: the model is not the bottleneck and it hasn't been for a while. The bottleneck is whether *you* know what you want. So I spent my lunch break knowing what I wanted.\n\nThe pitch fits in a paragraph. Top-down circular pond. Coloured petals spawn at the rim and drift inward on a current toward a whirlpool in the middle. Coloured lily pads sit on the rim. The player has exactly one verb: tap the water. A tap makes an expanding ripple, and the ripple shoves anything it passes away from the tap point. Land petals on the matching pad. Petals that reach the whirlpool, or land on the wrong pad, are gone. Three losses and the pond goes still.\n\nThat paragraph became a fifteen-section spec. Coordinate system. An entity table with radii. The physics section, which I labelled \"the core of the game\" and wrote like I meant it. A determinism section. A difficulty table by tier. Modes, rendering, audio, haptics. A tuning section listing every constant I expected to be wrong. Six milestones, in order, each with an acceptance criterion. And a section called *Out of scope for v1*, which is the section that keeps the other fourteen honest.\n\nForty minutes, with a sandwich. Then back to the day job.\n\nA good spec isn't a wish list. It's a list of decisions you've already made so the agent doesn't have to guess.\n\n## One impulse, not a force\n\nIf you take one engineering thing from this post, take this one. The obvious way to make a ripple push a petal is a continuous force while the petal sits inside the ring's band. It's also wrong. It makes the push depend on frame timing and band width, and it reads as mushy under your thumb.\n\nSo the spec says: the impulse is applied **exactly once per (ring, petal) pair**, at the step in which the ring front crosses the petal's centre. Crisp. Deterministic. Visually obvious, because the petal jumps the instant the line passes it. Here's what that became:\n\n```\nvoid _applyImpulses(double tPrev) {\n  for (final ring in snapshotRings) {\n    final rNow  = ring.radiusAt(t, speed);\n    final rPrev = math.max(0.0, ring.radiusAt(tPrev, speed));\n    final rNow2 = rNow * rNow, rPrev2 = rPrev * rPrev;\n\n    for (final petal in petals) {\n      if (ring.hitSet.contains(petal.id)) continue;\n      final d2 = petal.p.distance2To(ring.origin);\n      // The front crossed this petal between the last step and this one.\n      if (d2 <= rNow2 && d2 > rPrev2) {\n        final d   = math.sqrt(d2);\n        final dir = (petal.p - ring.origin) / d;\n        final strength = ring.energy * tuning.pushStrength * falloff(d);\n        final mult = _interferenceMultiplier(petal.hitTimestamps);\n        petal.v = petal.v + dir * (strength * mult);\n        ring.hitSet.add(petal.id);\n        petal.hitTimestamps.add(t);\n      }\n    }\n  }\n}\n\ndouble falloff(double d) =>\n    math.pow(clamp01(1 - d / tuning.ringMaxRadius), tuning.falloffExponent);\n```\n\nTwo squared radii and a set of ids. That's the whole trick. No square root until you know you need it, no per-frame band test, and the `hitSet` means a ring can never double-tap a petal no matter how the frame timing shakes out. The interference multiplier is the combo mechanic: two ripples crossing the same petal inside a short window push harder, which is how you get the \"landed three off one tap\" moments the score is built around.\n\nThe falloff curve is the feel. At the spec defaults, a tap right next to a petal moves it about half a pond radius; a tap on the far side nudges it a tenth. The agent ran the numbers before I ever played it and wrote them into a tuning log, which is exactly the kind of thing I'd never bother to do by hand and am glad exists.\n\n| Impulse at the spec defaults · pushStrength 1.1 · falloff 1.5 · drag 2.2 |  |  | \n|---|---|---|\n| Tap distance | Impulse (pond/s) | Travel (pond units) | \n|---|---|---|\n| 0.10 | 1.00 | 0.45 | \n| 0.30 | 0.78 | 0.36 | \n| 0.50 | 0.63 | 0.28 | \n| 1.00 | 0.25 | 0.11 | \n| 1.50 | 0.02 | 0.01 | \n\n## Determinism is not a nice-to-have\n\nDaily mode is \"one attempt, same pond for everyone.\" That sentence is a hard engineering requirement wearing a casual outfit. Same seed and same taps have to produce the same pond on an iPhone, a Pixel, and Chrome on a Mac, forever. So the simulation is pure Dart in its own package, fixed-step, with its own xoshiro PRNG instead of `dart:math`'s, and it never imports anything from the renderer. The renderer reads sim state. The sim does not know the renderer exists.\n\nAnd there's a test for it that I actually trust:\n\n```\nfor (final mode in SimMode.values) {\n  test('two sims, same seed + taps, identical after 5000 steps ($mode)', () {\n    const seed = 20260911;\n    final script = buildScript(seed);   // ~150 scripted taps, some double-taps\n    final a = runScript(seed, mode, script);\n    final b = runScript(seed, mode, script);\n    expect(a.snapshot(), equals(b.snapshot()));\n    expect(a.score, equals(b.score));\n    expect(a.tick, equals(5000));\n  });\n}\n\ntest('different seeds diverge', () { /* … */ });\n```\n\nFive thousand steps, every mode, scripted taps with deliberate double-taps to exercise the interference window and the tap cooldown, then a full state snapshot compared field by field. Plus the inverse: different seeds must *not* match, because a determinism test that passes on a sim that ignores its seed is worse than no test. Thirty-six tests run on every push. They ran green on all six PRs that afternoon.\n\n## The timeline\n\nLocal time, from the git log and my phone.\n\n1. **12:30.** Spec, over lunch. Fifteen sections. Working title \"Ripple\".\n2. **17:15.** Handed the spec to Claude Code. Build milestones M0 through M6 in order, tests first for the sim, keep the sim pure. Then, in a second thread, asked it to research the name.\n3. **17:40.** \"Ripple\" was taken, obviously. The agent checked the App Store search API, Google Play, the USPTO database and whois for a shortlist.**Lilydrift** cleared everything. Runner-up was \"Padfall\". I bought lilydrift.com while the build was still compiling.\n4. **18:29.** First commit: Flutter + Flame, all six milestones, 35 sim tests green. I hadn't touched the keyboard.\n5. **18:30 → 19:20.** Dinner. Dog walk (she has more Instagram followers than I do:[@missy.butterbean](https://instagram.com/missy.butterbean) ). Meanwhile: GitHub repo, CI, a deploy pipeline, the Cloudflare Pages project, the privacy page.\n6. **19:22.** First production deploy. lilydrift.com resolving, cross-origin isolated, wasm renderer running multi-threaded.\n7. **19:30 → 20:18.** Real phones. iPhone SE, Pixel 8 Pro. Five more PRs: icons, footer, art, phone readability, pad contrast, a how-to-play screen.\n8. **20:18.** Last merge. Done. Not \"done for now\". Done.\n\n## GitHub Pages lost on a header\n\nI host this site on GitHub Pages and I like it. It was the default answer for the game too, and it lost to a single HTTP header. Flutter's wasm renderer only runs multi-threaded when the page is cross-origin isolated, which means the HTML has to come back with `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` set. GitHub Pages cannot set response headers. At all. Cloudflare Pages honours a `_headers` file in the build output, and Flutter copies anything in `web/` into the build, so the whole fix is one text file in the repo:\n\n```\n/*\n  Cross-Origin-Opener-Policy: same-origin\n  Cross-Origin-Embedder-Policy: require-corp\n\n# entry points aren't content-hashed; always revalidate\n/index.html\n  Cache-Control: no-cache\n/flutter_bootstrap.js\n  Cache-Control: no-cache\n```\n\n`require-corp` is the strict one. It blocks any cross-origin asset that doesn't opt in, which is fine here because the build uses `--no-web-resources-cdn`, so CanvasKit and the wasm bundle come from the app's own origin. Load a font from somewhere else later and it'll silently fail. That's a footgun worth writing down, so it's in the README.\n\nThe deploy is a GitHub Action: run the tests, build with `--wasm`, push `build/web` with Cloudflare's wrangler action. Main goes to production. Every other branch gets a preview URL. The first run failed because I hadn't added the API token secret yet, which is the correct way for a first run to fail.\n\n## The phone told the truth\n\nOn my desktop the game was crisp and readable. On the Pixel, the petals were *tiny*. Everything scales with the pond, the pond scales with the screen width, and at 412 logical pixels wide a petal came out about nine pixels in radius. Legible if you squint. Casual games are not supposed to require squinting.\n\nThe tempting fix is to bump the petal radius in the tuning. That's the wrong fix, because the petal radius is a *simulation* constant. Change it and the Daily pond is a different pond on phones than on desktops. The right fix is render-only:\n\n```\n/// Render-only magnification for small objects so they stay legible on\n/// phones. Never below 1, so desktop is unchanged, and capped so petals\n/// don't swallow the pads. The sim never sees this.\ndouble get detailScale =>\n    (minPetalPx / (0.045 * scale)).clamp(1.0, 1.7);\n\nstatic const double minPetalPx = 13;\n```\n\nPetals, pad outlines, thistles and dragonflies all draw through that one scalar. A phone gets roughly 1.5x. Desktop gets exactly 1.0. The sim is untouched and the determinism test doesn't know anything happened.\n\nWhile I was in there I threw out the petal art. The first pass was clean silhouettes: a scalloped circle, a star, a heart, a teardrop. Fine, but generic. I asked for flowers in the spirit of the sky flowers in Bikini Bottom, flat colour with a fat ink outline and a bit of wonk, and the agent redrew all four as vector paths. The daisy is the union of six overlapping discs. The star has uneven tips on purpose. The silhouettes had to stay recognisable because they're the colour-blind contract: every colour has a shape, and the pads carry the same shape as an outline. That's a rule, not a theme.\n\nThat preview harness is the thing I'd point at if someone asked what \"agent coding done well\" looks like. Nobody asked for it. It exists because the agent needed to see its own output to iterate on art, so it wrote a test that renders the shapes at every size that matters and saves a picture. Then it looked at the picture, said the daisy read as a gear, and fixed the daisy. That's the loop. That's the whole loop.\n\n## What went wrong, because something always does\n\n**The stock Flutter icon showed up on the web install.** My fault. When I created the Cloudflare Pages project I uploaded a stale build by hand as a placeholder, then opened *that* deployment instead of the one CI shipped. Deleted it. The real fix was that the favicon was a single 16px file, so that got redone properly along the way.\n\n**The help button sat on top of the logo on a phone.** A top-right button and a 40px letter-spaced wordmark don't share 412 pixels. Moved it to its own row. Caught in the browser at phone width before it ever hit a device.\n\n**`flutter install` doesn't build for iOS.** It does for Android. It doesn't for iOS. This is documented nowhere I looked and cost one failed install. Now it's in the README.\n\n**Wireless adb pairing failed with \"protocol fault\".** Twice, with valid codes. The cause was a stale adb server on the Mac that some other tool had started. `adb kill-server`, pair again, instantly fine. None of these are model problems. They're the same problems I'd have had solo, and they got fixed faster because the agent could read the logs while I read the phone.\n\n## What's not done\n\nI want to be precise here because \"shipped\" gets abused. The web version is live at lilydrift.com and it's the real game. The iOS and Android builds are on my phones and they're the real game. What isn't done: the store listings, the in-app product for the one-time unlock, an Android upload keystore, and the milestone I care about most, the human feel-check. Every tuning constant is still at the spec default. The numbers in that table above are reasonable. They are not yet *right*, and no agent is going to tell me whether a tap feels good. That's a weekend with a debug overlay and my thumb.\n\nThe spec was the work. The agent was the typing. The phone was the review.\n\n## The actual lesson\n\nSix hours of wall clock with a workday in the middle. Maybe three hours of my attention, most of it spent reading diffs, playing builds, and saying \"no, bigger\" about petals. The game is real. The code is clean, and I mean that in the boring, specific way: the sim has no rendering imports, the entitlements are one abstraction with three backends, the strings are in one file, and the tests would catch me if I broke the thing the Daily mode depends on.\n\nI've been writing software for a long time, and the idea that a well-specified casual game is now a Friday afternoon is still a little disorienting. The bar for \"what's worth building\" just moved. Not because the model got smart last week. Because I wrote down what I wanted before I asked for it.\n\nGo tap the water.\n\n[lilydrift.com](https://lilydrift.com)(free, web; iOS and Android coming)\n\nStack → Flutter 3.47 · Flame 1.38 · Claude Code · Gemini for the icon · Cloudflare Pages · GitHub Actions\n\n[← All field notes](/field-notes/)", "url": "https://wpnews.pro/news/spec-at-lunch-game-by-eight", "canonical_source": "https://littletheta.com/field-notes/spec-at-lunch-game-by-eight", "published_at": "2026-09-12 02:47:41+00:00", "updated_at": "2026-09-12 02:57:00.706099+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Flutter", "Dart"], "alternates": {"html": "https://wpnews.pro/news/spec-at-lunch-game-by-eight", "markdown": "https://wpnews.pro/news/spec-at-lunch-game-by-eight.md", "text": "https://wpnews.pro/news/spec-at-lunch-game-by-eight.txt", "jsonld": "https://wpnews.pro/news/spec-at-lunch-game-by-eight.jsonld"}}