The Empty SERP Was a Difficulty Signal: Building Conway's Sprouts in the Browser

lans.cloud is ~87 free single-purpose web tools, and lately a growing shelf of browser games. When we vetted the games niche, one pattern held across ~30 candidate queries: every popular classic — snake, 2048, minesweeper, hangman, connect four — is owned by exact-match domains whose headline is already "free, no ads, no sign-up." In games, our usual wedge is their wedge. The only winnable spots were the ones where page 1 is paid PowerPoint templates, printable PDFs, or hobby pages.

And then there was Sprouts. A genuinely famous game — invented at Cambridge in 1967 by John Conway and Michael Paterson, popularized by Martin Gardner, studied in real papers — and its search results page was lesson-plan blogs and one student GitHub project. Zero commercial incumbents. For a game with a Wikipedia page and a fan base of math teachers, that's bizarre.

Unless you think about what building it actually requires. Then it's not bizarre at all. An empty SERP for a famous thing usually isn't an oversight — it's a difficulty signal. Every other game on that vetting list is discrete: tap a cell, drop a disc, pick a square. Sprouts is played by drawing curves that must never cross. The moment you commit to that, you've signed up for computational geometry, planar topology, and a game-over condition that's a small research problem. Nobody built it because nobody wanted to write that.

So we built it: the sprouts game, two players, one screen, real freehand drawing. This is what it took — including the two bugs a review pass caught, one of which was created by the fix for the other.

Sixty seconds of rules

Start with a few dots. On your turn, draw a curve from one dot to another (or from a dot back to itself), then place a new dot on the curve you just drew. Two constraints: curves may never cross — not each other, not themselves — and no dot may ever have more than three curve-ends touching it. When a player has no legal line left, the game ends; last line wins (or loses, in misère).

The rules fit on a napkin, but they hide a beautiful counting theorem. Give every dot three "lives" — one per line it can still accept. A game with n dots starts with 3n lives; every move burns two (one per end) and creates a dot that arrives with one life left. Net: every move costs exactly one life, so no game lasts longer than 3n−1 moves. The move counter under our board is that theorem rendered live, and the reference table on the page is computed from the same functions the engine runs on, so it can't drift.

Where the difficulty actually lives

Three problems, in ascending order of pain.

1. Validating a freehand stroke. A stroke arrives as a polyline of pointer samples. Before it becomes a move, it has to survive: endpoints on living dots (a self-loop needs two free lives, because a loop touches its dot twice), no intersection with any existing curve, no self-intersection, a clearance margin around every dot it isn't attached to, and enough length to fit the new dot. Segment-against-segment intersection over a few hundred stored segments is the easy part. The subtle part is that curves legitimately meet at dots — three lines fanning out of one dot all "touch" near its center, and a naive intersection test rejects every one of them. The engine excuses contacts only when both segments sit within a small radius of a dot that both pieces are actually attached to. Get that radius wrong in one direction and legal moves bounce; wrong in the other and real crossings slip through next to a dot. (A review pass later tightened it from 3.5 units to 2.5 — more on that theme below.)

2. The topology bookkeeping. A valid curve doesn't just get stored — the new dot is placed at its arc-length midpoint, which splits the curve into two half-curves, each a first-class object future strokes must not cross. Degrees update (+1 per end, +2 for a loop, the newborn starts at 2), and everything is immutable so undo can be a plain history stack.

3. Knowing the game is over. This is the one that keeps sprouts off the internet. In connect four, "no legal move" means the board is full. In sprouts, a legal move exists iff either

  • some living dot has ≤1 line (it can always draw a little loop onto itself — a curve is one-dimensional, there's always room beside the dot), or
  • two living dots lie on the boundary of the same planar region, so a curve can still join them.

That second condition is real topology. Every curve you draw carves the plane into regions, and dots stranded in different regions can never be connected again — that's the entire strategy of the game. To detect it, the engine rasterizes the board into a 256×256 grid, blocks cells near curves and dots, flood-fills the free cells into labelled regions, and then asks, for each living dot, which regions ring it:

export function hasLegalMove(game: SproutsGame): boolean {
  const live = game.spots.filter((spot) => spot.degree < 3);
  // A spot with ≤1 line can always loop onto itself.
  if (live.some((spot) => spot.degree <= 1)) return true;
  if (live.length < 2) return false;
  const labels = buildRegionLabels(game); // raster flood fill
  const rings = live.map((spot) => ringRegions(labels, spot));
  // Connectable iff two live spots share a reachable region.
  for (let i = 0; i < rings.length; i++)
    for (let j = i + 1; j < rings.length; j++)
      for (const label of rings[i]) if (rings[j].has(label)) return true;
  return false;
}

The ring-of-regions trick earns its keep on one nasty detail: a dot with two lines sits between regions — its two curves slice its own neighborhood into sectors that may open into different faces of the plane. Sampling the free cells all the way around the dot picks up every sector for free.

Review catch #1: the false game-over

Before shipping, the diff went through an adversarial review pass (a second agent with fresh context — standard practice on this project, and this wave is why). The reviewer didn't just flag the flood fill as suspicious; it proved a failure. Rasterization is conservative: curves block cells about a cell wide on each side, so two curves running very close together merge into one solid raster wall — even though a curve is one-dimensional and can thread any positive gap. The empirical table from the review, ring around one live dot, second live dot outside, gap of varying width:

gap ≈ 1.6 units   hasLegalMove = false   ← false game-over
gap ≈ 2.1 units   hasLegalMove = false   ← false game-over
gap ≈ 3.1 units   hasLegalMove = true
gap ≈ 6.2 units   hasLegalMove = true

Below roughly 2.5 units (out of a 100-unit board), the engine would declare the game over, announce a winner, and lock the board — while a perfectly legal move still existed. Dense endgames on 5–6 dots can produce corridors like that without anyone playing adversarially.

The fix came in two parts, and the second is the interesting one:

  1. Double the grid (128 → 256), which shrinks the failure zone to gaps narrower than the rendered stroke itself — corridors a fingertip couldn't thread anyway.
  2. Make the exact validator outrank the raster. The stroke validator is ground truth: it does exact segment geometry, no rasterization. So the board now stays interactive after the ending. If the region check called the game early and you can still see a legal line, draw it — it passes exact validation, the game reopens, and the premature win comes off the scoreboard. If the game is truly over, every stroke you attempt gets rejected with its concrete reason ("that dot already has 3 lines"). The conservative check announces; the exact check decides. A small hint under the board makes it honest: "Still see a legal line? Draw it — the game continues."

I like this pattern a lot: when you have a fast-but-approximate decision procedure and a slow-but-exact one, don't let the approximation hold the door shut. Let it make the announcement and let reality overrule it.

Review catch #2: my fix broke the scoreboard

Wave two of review (yes, we ran another round — this site's waves alternate build and review) found the bug that fix created. Scores were a free-floating useState, incremented when a game ended, decremented when a reopening stroke took a premature win back. But undo restored board positions without touching scores. Walk the sequence:

win declared    → scoreboard: Red 1     banner: "Red wins!"
reopening line  → scoreboard: Red 0     game continues
undo            → banner: "Red wins!"   scoreboard: Red 0   ← disagree
reopen again    → scoreboard: Red −1                        ← rendered verbatim

"Red −1," on a real screen, because the decrement fired twice against one increment. The reviewer reached it through the actual UI: the over-board is deliberately interactive (that was the whole point of fix #2), so the loser can draw the reopening line, which resurfaces the Undo button, which steps back across a scoring boundary that nothing re-credits.

The repair is the same lesson I keep relearning in different costumes (the array-index override bug was the last costume): derived state must live in the same atom as the state it derives from. The undo history now stores frames — { game, scores } — and one pure function owns every scoring transition, including the reopen take-back:

export function nextFrame(frame: SproutsFrame, game: SproutsGame): SproutsFrame {
  const scores = { ...frame.scores };
  if (frame.game.over && frame.game.winner) scores[frame.game.winner] -= 1; // reopened
  if (game.over && game.winner) scores[game.winner] += 1;
  return { game, scores };
}

Undo pops a whole frame, so the banner and the scoreboard cannot disagree — there is no sequence of moves, reopenings, and undos that desyncs them, and the exact failure sequence above is now a unit test. Once scores rode inside the frames, the fix wasn't code that handles the edge case; it's a shape that makes the edge case unrepresentable.

What shipped, and the lesson I'd generalize

The game is live at lans.cloud/sprouts-game: 2–6 starting dots, misère mode, lives shown as pips on every dot, per-rule rejection messages when a stroke is illegal, multi-step undo, and the 3n−1 counter proving Conway's theorem every game. The engine carries 46 unit tests — including a scripted one-dot game played to its theoretical maximum in both rule sets, and region-detection tests that lock the corridor resolution — and the drawing surface was verified with real pointer and touch event streams in headless Chrome, because a drawing game that only works with a mouse is half a game. It sits in the same corner as our nim (solved, with an unbeatable computer) and hex (famously unsolved) — the pencil-and-paper math shelf.

Two takeaways worth carrying out of this project:

Empty SERPs are priced in effort. When a famous, in-demand thing has no decent implementation, the market isn't asleep — it's telling you the build cost. That's bad news if you wanted cheap content, and great news if you're willing to pay the cost, because the same difficulty that kept everyone else out is your moat once you're in.

Adversarial review pays twice. The first pass caught a bug I couldn't have found by playing normally (the corridor case needs a contrived-looking position that dense play nonetheless reaches). The second pass caught the bug my fix introduced — the classic second-order failure, where the patch is correct in isolation and wrong against the rest of the state machine. If your review process stops after one round, you keep exactly the bugs your fixes create.