fuzzyworld

basin / commit

Add puzzle gameplay: springs, canyon walls, and configurable boards

a7bf123ac1c7abaab500cbe2bcdebf4fa80a2eed Β· Pavel Safronov Β· 2026-07-01 06:48:47 -0700 Β· parents f2b5d413fdb6

Add puzzle gameplay: springs, canyon walls, and configurable boards

Turn the watershed sandbox into a puzzle game with configurable, shareable boards.

- Puzzle layer: hidden multi-spring solutions, a target edge signature,
  exploration probes, locked/labelled springs, and win detection.
- Per-side edge types: each side is ocean or a mountain wall (canyons);
  water drains only to ocean sides, drainage seeded from ocean edges.
- Config UI: size, spring count, per-side walls, and terrain seed as
  controls, all captured in one shareable 8-char base64url code kept in
  two-way sync with the controls and the ?code= URL.
- Vitest suite (23 tests) covering flow, drainage, puzzle, and config code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
 index.html                  |  37 ++++-
 package-lock.json           | 388 +++++++++++++++++++++++++++++++++++++++++++-
 package.json                |   7 +-
 readme.md                   |  55 ++++++-
 src/game.ts                 |  77 ++++++---
 src/main.ts                 |  86 +++++++++-
 src/model/flow.test.ts      | 126 ++++++++++++++
 src/model/flow.ts           |  41 +++--
 src/model/heightmap.test.ts | 134 +++++++++++++++
 src/model/heightmap.ts      |  91 ++++++++---
 src/model/puzzle.test.ts    | 152 +++++++++++++++++
 src/model/puzzle.ts         | 228 ++++++++++++++++++++++++++
 src/render/renderer.ts      |  76 ++++++++-
 src/style.css               |  96 ++++++++++-
 14 files changed, 1515 insertions(+), 79 deletions(-)

diff --git a/index.html b/index.html
index 8361dc1..99a30f5 100644
--- a/index.html
+++ b/index.html
@@ -10,13 +10,42 @@
       <header>
         <img id="logo" class="logo" alt="basin logo" />
         <h1>basin</h1>
-        <span id="seed" class="seed"></span>
-        <button id="reset">Reset</button>
+        <button id="randomize">🎲 Random</button>
       </header>
+      <section id="config" class="config">
+        <label class="ctl">
+          Size <span id="cfg-size-val" class="val"></span>
+          <input type="range" id="cfg-size" min="5" max="20" step="1" />
+        </label>
+        <label class="ctl">
+          Springs
+          <input type="number" id="cfg-springs" min="1" max="6" step="1" />
+        </label>
+        <fieldset class="ctl edges">
+          <legend>Walls</legend>
+          <label><input type="checkbox" id="cfg-wall-n" /> N</label>
+          <label><input type="checkbox" id="cfg-wall-e" /> E</label>
+          <label><input type="checkbox" id="cfg-wall-s" /> S</label>
+          <label><input type="checkbox" id="cfg-wall-w" /> W</label>
+        </fieldset>
+        <label class="ctl">
+          Terrain
+          <span class="seedrow">
+            <input type="number" id="cfg-seed" min="0" step="1" />
+            <button id="cfg-seed-roll" title="Randomize terrain">🎲</button>
+          </span>
+        </label>
+        <label class="ctl code">
+          Code
+          <input type="text" id="cfg-code" spellcheck="false" autocomplete="off" />
+        </label>
+      </section>
       <canvas id="board"></canvas>
+      <p id="status" class="status"></p>
       <p class="hint">
-        Tap a cell to release water. It branches downhill and exits wherever it
-        reaches an edge.
+        Find the hidden springs that make water exit exactly at the gold edges.
+        Water leaves only at ocean (blue) sides β€” mountain walls block it. Tap to
+        explore the terrain; land on a spring and it locks in.
       </p>
       <footer class="credit">
         Logo by
diff --git a/package-lock.json b/package-lock.json
index a35ad3c..78cfe58 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
       "version": "0.0.0",
       "devDependencies": {
         "typescript": "^5.6.3",
-        "vite": "^6.0.7"
+        "vite": "^6.0.7",
+        "vitest": "^4.1.9"
       }
     },
     "node_modules/@esbuild/aix-ppc64": {
@@ -454,6 +455,13 @@
         "node": ">=18"
       }
     },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@rollup/rollup-android-arm-eabi": {
       "version": "4.62.2",
       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -804,6 +812,31 @@
         "win32"
       ]
     },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -811,6 +844,153 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@vitest/expect": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+      "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.1.0",
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "4.1.9",
+        "@vitest/utils": "4.1.9",
+        "chai": "^6.2.2",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+      "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "4.1.9",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.21"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+      "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+      "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "4.1.9",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+      "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.9",
+        "@vitest/utils": "4.1.9",
+        "magic-string": "^0.30.21",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+      "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+      "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.9",
+        "convert-source-map": "^2.0.0",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/chai": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/es-module-lexer": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
+      "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/esbuild": {
       "version": "0.25.12",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -853,6 +1033,26 @@
         "@esbuild/win32-x64": "0.25.12"
       }
     },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/fdir": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -886,6 +1086,16 @@
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
     },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
     "node_modules/nanoid": {
       "version": "3.3.15",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -905,6 +1115,27 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/obug": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+      "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -999,6 +1230,13 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1009,6 +1247,37 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+      "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+      "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.17",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1026,6 +1295,16 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tinyrainbow": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+      "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/typescript": {
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1114,6 +1393,113 @@
           "optional": true
         }
       }
+    },
+    "node_modules/vitest": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+      "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "4.1.9",
+        "@vitest/mocker": "4.1.9",
+        "@vitest/pretty-format": "4.1.9",
+        "@vitest/runner": "4.1.9",
+        "@vitest/snapshot": "4.1.9",
+        "@vitest/spy": "4.1.9",
+        "@vitest/utils": "4.1.9",
+        "es-module-lexer": "^2.0.0",
+        "expect-type": "^1.3.0",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "std-env": "^4.0.0-rc.1",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^1.0.2",
+        "tinyglobby": "^0.2.15",
+        "tinyrainbow": "^3.1.0",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "4.1.9",
+        "@vitest/browser-preview": "4.1.9",
+        "@vitest/browser-webdriverio": "4.1.9",
+        "@vitest/coverage-istanbul": "4.1.9",
+        "@vitest/coverage-v8": "4.1.9",
+        "@vitest/ui": "4.1.9",
+        "happy-dom": "*",
+        "jsdom": "*",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/coverage-istanbul": {
+          "optional": true
+        },
+        "@vitest/coverage-v8": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        },
+        "vite": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index 9c26bef..f750f6b 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,13 @@
   "scripts": {
     "dev": "vite",
     "build": "tsc && vite build",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest"
   },
   "devDependencies": {
     "typescript": "^5.6.3",
-    "vite": "^6.0.7"
+    "vite": "^6.0.7",
+    "vitest": "^4.1.9"
   }
 }
diff --git a/readme.md b/readme.md
index 843137b..50d0ecb 100644
--- a/readme.md
+++ b/readme.md
@@ -1,3 +1,56 @@
 # basin
 
-a game about water basins
\ No newline at end of file
+A puzzle game about water basins.
+
+Each board has a heightmap that falls to the sea at its **ocean** sides. Water
+released from a *spring* flows downhill, branching to every strictly-lower
+neighbour, and exits wherever it reaches an ocean edge. Every board is generated
+with a hidden set of springs; their combined flow lights up a **target edge
+signature** (the gold-outlined border cells).
+
+Each of the four sides is independently rolled **ocean** (blue β€” water exits) or
+a **mountain wall** (high ground that blocks water). Walls make canyons: e.g.
+north + south walls with east + west oceans is a valley that drains sideways.
+Water can never leave through a wall.
+
+**Goal:** find the positions of the hidden springs that reproduce that signature.
+Tap cells to explore the terrain β€” a wrong tap just previews its flow (and marks
+any edges it wrongly wets in red), while landing on a real spring **locks it in**
+(green, numbered). Find them all to solve the board.
+
+## Configuring boards
+
+Every axis of a board is a control in the panel: **size**, **spring count**, which
+sides are **walls**, and the **terrain seed**. All of it is captured in a single
+shareable **code** β€” an 8-character token (e.g. `B0B0bgTL`) that packs the four
+axes into 44 bits and base64url-encodes them, so the settings aren't legible at a
+glance (reversible obfuscation, not encryption). The code and the controls are
+two-way views of the same config β€” changing any control rewrites the code, and
+pasting a code sets every control.
+
+The code also lives in the URL as `?code=<code>`, so a link reproduces the exact
+board. **🎲 Random** rolls a whole new config; the 🎲 next to Terrain rerolls just
+the terrain while keeping the other settings.
+
+## Develop
+
+```bash
+npm install
+npm run dev      # Vite dev server
+npm run build    # type-check + production build
+npm test         # run the model tests (Vitest)
+```
+
+## Architecture
+
+The model (typed arrays) is decoupled from the renderer so a tiled/WebGL renderer
+can replace Canvas2D for large boards later.
+
+- `src/model/heightmap.ts` β€” terrain + per-side edge types (ocean/wall) +
+  priority-flood depression filling (guarantees every cell drains to an *ocean*
+  edge, seeded only from ocean sides).
+- `src/model/flow.ts` β€” the branching watershed flood (single- and multi-spring).
+- `src/model/puzzle.ts` β€” the board `Config`, its shareable code (encode/decode),
+  config-driven puzzle generation, and the solution check.
+- `src/render/renderer.ts` β€” Canvas2D board + puzzle overlay.
+- `src/game.ts` β€” puzzle state: exploration probe, locked springs, win detection.
diff --git a/src/game.ts b/src/game.ts
index b70bdb0..492c15d 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -1,48 +1,79 @@
-import { mulberry32 } from './rng';
-import { generateDome } from './model/heightmap';
-import type { Heightmap } from './model/heightmap';
-import { simulateFlow } from './model/flow';
-import type { FlowResult } from './model/flow';
+import { generatePuzzle, isSolutionSpring } from './model/puzzle';
+import type { Config, Puzzle } from './model/puzzle';
+import { simulateFlows } from './model/flow';
+import type { Cell } from './model/flow';
 import { Renderer } from './render/renderer';
 
-/** Board edge length in cells. Larger reads more clearly as terrain. */
-const BOARD_SIZE = 24;
-
 export class Game {
-  private hm!: Heightmap;
-  private flow: FlowResult | null = null;
+  private puzzle!: Puzzle;
+  /** Locked springs found so far, in discovery order. */
+  private found: Cell[] = [];
+  /** Current transient exploration tap (replaced by the next non-spring tap). */
+  private probe: Cell | null = null;
+  private solved = false;
   private renderer: Renderer;
-  private seed = 0;
 
   constructor(
     private canvas: HTMLCanvasElement,
-    private seedEl: HTMLElement,
+    private statusEl: HTMLElement,
   ) {
     this.renderer = new Renderer(canvas);
     canvas.addEventListener('click', (e) => this.onClick(e));
     window.addEventListener('resize', () => this.draw());
   }
 
-  /** Start a fresh board. Reset re-rolls the seed (and thus the whole config). */
-  newGame(seed: number = (Math.random() * 2 ** 32) >>> 0): void {
-    this.seed = seed >>> 0;
-    const rng = mulberry32(this.seed);
-    this.hm = generateDome(BOARD_SIZE, BOARD_SIZE, rng);
-    this.flow = null;
-    this.seedEl.textContent = `seed ${this.seed}`;
+  /** Load a board from a config, clearing any progress. */
+  load(config: Config): void {
+    this.puzzle = generatePuzzle(config);
+    this.found = [];
+    this.probe = null;
+    this.solved = false;
+    this.updateStatus();
     this.draw();
   }
 
   private onClick(e: MouseEvent): void {
+    if (this.solved) return;
     const rect = this.canvas.getBoundingClientRect();
-    const cell = this.renderer.pickCell(this.hm, e.clientX - rect.left, e.clientY - rect.top);
+    const cell = this.renderer.pickCell(this.puzzle.hm, e.clientX - rect.left, e.clientY - rect.top);
     if (!cell) return;
-    // One spring at a time: a new tap replaces the previous flow.
-    this.flow = simulateFlow(this.hm, cell.x, cell.y);
+
+    // Found springs are locked (immutable): tapping one again does nothing.
+    if (this.found.some((f) => f.x === cell.x && f.y === cell.y)) return;
+
+    if (isSolutionSpring(this.puzzle, cell)) {
+      // Correct position β€” lock it in and clear the transient probe.
+      this.found.push(cell);
+      this.probe = null;
+      if (this.found.length === this.puzzle.solution.length) this.solved = true;
+    } else {
+      // Exploration only: one probe at a time, alongside the locked springs.
+      this.probe = cell;
+    }
+
+    this.updateStatus();
     this.draw();
   }
 
+  /** Springs driving the currently-shown flow: locked + the live probe. */
+  private currentSprings(): Cell[] {
+    return this.probe ? [...this.found, this.probe] : this.found;
+  }
+
+  private updateStatus(): void {
+    const n = this.puzzle.solution.length;
+    this.statusEl.textContent = this.solved
+      ? `Solved! All ${n} springs found πŸŽ‰`
+      : `${this.found.length} / ${n} springs found`;
+  }
+
   private draw(): void {
-    this.renderer.render(this.hm, this.flow);
+    const springs = this.currentSprings();
+    const flow = springs.length ? simulateFlows(this.puzzle.hm, springs) : null;
+    this.renderer.render(this.puzzle.hm, flow, {
+      target: this.puzzle.target,
+      found: this.found,
+      probe: this.probe,
+    });
   }
 }
diff --git a/src/main.ts b/src/main.ts
index 94440ab..6af3ea9 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,13 @@
 import './style.css';
 import logoUrl from '../water.png';
 import { Game } from './game';
+import {
+  decodeConfig,
+  encodeConfig,
+  normalizeConfig,
+  randomConfig,
+} from './model/puzzle';
+import type { Config } from './model/puzzle';
 
 // Logo asset β†’ on-page logo and favicon.
 document.querySelector<HTMLImageElement>('#logo')!.src = logoUrl;
@@ -9,10 +16,79 @@ favicon.rel = 'icon';
 favicon.href = logoUrl;
 document.head.appendChild(favicon);
 
-const canvas = document.querySelector<HTMLCanvasElement>('#board')!;
-const seed = document.querySelector<HTMLElement>('#seed')!;
+const $ = <T extends HTMLElement>(sel: string): T => document.querySelector<T>(sel)!;
 
-const game = new Game(canvas, seed);
-game.newGame();
+const canvas = $<HTMLCanvasElement>('#board');
+const status = $<HTMLElement>('#status');
+const game = new Game(canvas, status);
 
-document.querySelector<HTMLButtonElement>('#reset')!.addEventListener('click', () => game.newGame());
+// Config controls.
+const sizeInput = $<HTMLInputElement>('#cfg-size');
+const sizeVal = $<HTMLElement>('#cfg-size-val');
+const springsInput = $<HTMLInputElement>('#cfg-springs');
+const wall = {
+  n: $<HTMLInputElement>('#cfg-wall-n'),
+  e: $<HTMLInputElement>('#cfg-wall-e'),
+  s: $<HTMLInputElement>('#cfg-wall-s'),
+  w: $<HTMLInputElement>('#cfg-wall-w'),
+};
+const seedInput = $<HTMLInputElement>('#cfg-seed');
+const codeInput = $<HTMLInputElement>('#cfg-code');
+
+// The config is the single source of truth; the code and every control are just
+// two-way views of it.
+let config: Config;
+
+/** Reflect the current config back into every control + the shared code + URL. */
+function syncControls(): void {
+  sizeInput.value = String(config.size);
+  sizeVal.textContent = `${config.size}Γ—${config.size}`;
+  springsInput.value = String(config.springCount);
+  wall.n.checked = config.edges.n === 'wall';
+  wall.e.checked = config.edges.e === 'wall';
+  wall.s.checked = config.edges.s === 'wall';
+  wall.w.checked = config.edges.w === 'wall';
+  seedInput.value = String(config.terrainSeed);
+  codeInput.value = encodeConfig(config);
+
+  const url = new URL(location.href);
+  url.searchParams.set('code', codeInput.value);
+  history.replaceState(null, '', url);
+}
+
+/** Adopt a new config: normalise it, rebuild the board, resync the UI. */
+function apply(next: Config): void {
+  config = normalizeConfig(next);
+  game.load(config);
+  syncControls();
+}
+
+const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean');
+
+// Live-update the size label while dragging, but only rebuild on release.
+sizeInput.addEventListener('input', () => (sizeVal.textContent = `${sizeInput.value}Γ—${sizeInput.value}`));
+sizeInput.addEventListener('change', () => apply({ ...config, size: Number(sizeInput.value) }));
+springsInput.addEventListener('change', () =>
+  apply({ ...config, springCount: Number(springsInput.value) }),
+);
+for (const side of ['n', 'e', 's', 'w'] as const) {
+  wall[side].addEventListener('change', () =>
+    apply({ ...config, edges: { ...config.edges, [side]: edgeOf(wall[side].checked) } }),
+  );
+}
+seedInput.addEventListener('change', () =>
+  apply({ ...config, terrainSeed: Number(seedInput.value) >>> 0 }),
+);
+$<HTMLButtonElement>('#cfg-seed-roll').addEventListener('click', () =>
+  apply({ ...config, terrainSeed: (Math.random() * 2 ** 32) >>> 0 }),
+);
+codeInput.addEventListener('change', () => {
+  const decoded = decodeConfig(codeInput.value);
+  if (decoded) apply(decoded);
+  else syncControls(); // reject: snap back to the current config
+});
+$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomConfig()));
+
+// Start from a shared code if present, otherwise a fresh random board.
+const codeParam = new URLSearchParams(location.search).get('code');
+apply((codeParam && decodeConfig(codeParam)) || randomConfig());
diff --git a/src/model/flow.test.ts b/src/model/flow.test.ts
new file mode 100644
index 0000000..0d20dca
--- /dev/null
+++ b/src/model/flow.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect } from 'vitest';
+import { simulateFlow, simulateFlows } from './flow';
+import type { Edges, Heightmap } from './heightmap';
+import { ALL_OCEAN, generateDome } from './heightmap';
+import { mulberry32 } from '../rng';
+
+/** Build a heightmap from an explicit grid of rows. */
+function grid(rows: number[][], edges: Edges = ALL_OCEAN): Heightmap {
+  const height = rows.length;
+  const width = rows[0].length;
+  const data = new Float32Array(width * height);
+  for (let y = 0; y < height; y++) {
+    for (let x = 0; x < width; x++) data[y * width + x] = rows[y][x];
+  }
+  return { width, height, data, edges };
+}
+
+/** Collect wet cells as "x,y" strings for order-independent comparison. */
+function wetCells(hm: Heightmap, wet: Uint8Array): Set<string> {
+  const s = new Set<string>();
+  for (let y = 0; y < hm.height; y++) {
+    for (let x = 0; x < hm.width; x++) if (wet[y * hm.width + x]) s.add(`${x},${y}`);
+  }
+  return s;
+}
+
+describe('simulateFlow', () => {
+  it('flows downhill along a ramp and stops at the low edge', () => {
+    // Heights decrease left→right; a spring at the top of the ramp wets the row.
+    const hm = grid([
+      [5, 4, 3],
+      [5, 4, 3],
+      [5, 4, 3],
+    ]);
+    const { wet, outflow } = simulateFlow(hm, 0, 1);
+    expect(wetCells(hm, wet)).toEqual(new Set(['0,1', '1,1', '2,1']));
+    // Both ends sit on the border β†’ both are outflow cells.
+    expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(new Set(['0,1', '2,1']));
+  });
+
+  it('is strictly decreasing: equal-height neighbours stay dry', () => {
+    // A single peak over a flat plateau. Water reaches the 4 edge-mids (all < 3)
+    // but cannot cross from one plateau cell to an equal-height corner.
+    const hm = grid([
+      [2, 2, 2],
+      [2, 3, 2],
+      [2, 2, 2],
+    ]);
+    const { wet } = simulateFlow(hm, 1, 1);
+    expect(wetCells(hm, wet)).toEqual(new Set(['1,1', '1,0', '0,1', '2,1', '1,2']));
+  });
+
+  it('does not flow at all from a local pit', () => {
+    const hm = grid([
+      [9, 9, 9],
+      [9, 0, 9],
+      [9, 9, 9],
+    ]);
+    const { wet, outflow } = simulateFlow(hm, 1, 1);
+    expect(wetCells(hm, wet)).toEqual(new Set(['1,1']));
+    expect(outflow).toHaveLength(0);
+  });
+
+  it('reports outflow as exactly the wet border cells', () => {
+    const hm = grid([
+      [5, 4, 3],
+      [5, 4, 3],
+      [5, 4, 3],
+    ]);
+    const { wet, outflow } = simulateFlow(hm, 0, 1);
+    const expected = new Set<string>();
+    for (let y = 0; y < hm.height; y++) {
+      for (let x = 0; x < hm.width; x++) {
+        const isEdge = x === 0 || y === 0 || x === hm.width - 1 || y === hm.height - 1;
+        if (isEdge && wet[y * hm.width + x]) expected.add(`${x},${y}`);
+      }
+    }
+    expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(expected);
+  });
+});
+
+describe('simulateFlows', () => {
+  it('unions the wet sets: wet = OR of each spring flooded alone', () => {
+    const hm = generateDome(10, 10, mulberry32(7));
+    const a = simulateFlow(hm, 3, 3);
+    const b = simulateFlow(hm, 6, 6);
+    const both = simulateFlows(hm, [
+      { x: 3, y: 3 },
+      { x: 6, y: 6 },
+    ]);
+    for (let i = 0; i < both.wet.length; i++) {
+      expect(both.wet[i]).toBe(a.wet[i] | b.wet[i]);
+    }
+  });
+
+  it('does not exit through a wall side (water pools against it)', () => {
+    // Heights fall toward the north row; north is a wall, south is ocean.
+    const hm = grid(
+      [
+        [3, 3, 3],
+        [4, 4, 4],
+        [5, 5, 5],
+      ],
+      { n: 'wall', e: 'ocean', s: 'ocean', w: 'ocean' },
+    );
+    const { wet, outflow } = simulateFlow(hm, 1, 2);
+    // Water reaches the north wall cell...
+    expect(wetCells(hm, wet)).toEqual(new Set(['1,2', '1,1', '1,0']));
+    // ...but only the south ocean cell (the spring itself) counts as outflow.
+    expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(new Set(['1,2']));
+  });
+
+  it('deduplicates coincident springs', () => {
+    const hm = grid([
+      [5, 4, 3],
+      [5, 4, 3],
+      [5, 4, 3],
+    ]);
+    const one = simulateFlow(hm, 0, 1);
+    const dup = simulateFlows(hm, [
+      { x: 0, y: 1 },
+      { x: 0, y: 1 },
+    ]);
+    expect(wetCells(hm, dup.wet)).toEqual(wetCells(hm, one.wet));
+  });
+});
diff --git a/src/model/flow.ts b/src/model/flow.ts
index 736caa2..bf53787 100644
--- a/src/model/flow.ts
+++ b/src/model/flow.ts
@@ -1,5 +1,5 @@
 import type { Heightmap } from './heightmap';
-import { heightAt } from './heightmap';
+import { heightAt, isOceanEdge } from './heightmap';
 
 export interface Cell {
   x: number;
@@ -11,7 +11,8 @@ export interface FlowResult {
   wet: Uint8Array;
   /** Wet border cells β€” where water exits the board. */
   outflow: Cell[];
-  spring: Cell;
+  /** The springs that produced this flow (order preserved). */
+  springs: Cell[];
 }
 
 // 4-connected (cardinal) neighbours: N, E, S, W.
@@ -23,17 +24,34 @@ const NEIGHBORS: ReadonlyArray<readonly [number, number]> = [
 ];
 
 /**
- * Flood from a spring: a cell is wet iff it's reachable from the spring along a
- * path of strictly decreasing heights. Water branches to every lower neighbour,
- * never pools, and stops at interior pits or board edges.
+ * Flood from a single spring. Convenience wrapper over {@link simulateFlows}.
  */
 export function simulateFlow(hm: Heightmap, sx: number, sy: number): FlowResult {
+  return simulateFlows(hm, [{ x: sx, y: sy }]);
+}
+
+/**
+ * Flood from a set of springs and union their wet sets: a cell is wet iff it's
+ * reachable from *some* spring along a path of strictly decreasing heights.
+ * Water branches to every lower neighbour, never pools, and stops at interior
+ * pits or board edges.
+ *
+ * A shared visited array is sound for the union: floods only ever move downhill,
+ * so the first time a cell is wetted its entire strictly-lower descendant set is
+ * already scheduled β€” a later spring reaching the same cell adds nothing new.
+ */
+export function simulateFlows(hm: Heightmap, springs: readonly Cell[]): FlowResult {
   const { width, height } = hm;
   const wet = new Uint8Array(width * height);
   const stack: number[] = [];
 
-  wet[sy * width + sx] = 1;
-  stack.push(sx, sy);
+  for (const s of springs) {
+    const i = s.y * width + s.x;
+    if (!wet[i]) {
+      wet[i] = 1;
+      stack.push(s.x, s.y);
+    }
+  }
 
   while (stack.length) {
     const y = stack.pop()!;
@@ -51,13 +69,16 @@ export function simulateFlow(hm: Heightmap, sx: number, sy: number): FlowResult
     }
   }
 
+  // Water exits only where it reaches an *ocean* edge; wet cells that pool
+  // against a wall stop there and are not outflow.
   const outflow: Cell[] = [];
   for (let y = 0; y < height; y++) {
     for (let x = 0; x < width; x++) {
-      const isEdge = x === 0 || y === 0 || x === width - 1 || y === height - 1;
-      if (isEdge && wet[y * width + x]) outflow.push({ x, y });
+      if (wet[y * width + x] && isOceanEdge(hm.edges, x, y, width, height)) {
+        outflow.push({ x, y });
+      }
     }
   }
 
-  return { wet, outflow, spring: { x: sx, y: sy } };
+  return { wet, outflow, springs: springs.map((s) => ({ x: s.x, y: s.y })) };
 }
diff --git a/src/model/heightmap.test.ts b/src/model/heightmap.test.ts
new file mode 100644
index 0000000..2b1cf65
--- /dev/null
+++ b/src/model/heightmap.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest';
+import { generateDome, resolveDrainage, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap';
+import type { Edges, Heightmap } from './heightmap';
+import { simulateFlow } from './flow';
+import { mulberry32 } from '../rng';
+
+/** True iff water released at (x, y) escapes to an ocean edge. */
+function drains(hm: Heightmap, x: number, y: number): boolean {
+  return simulateFlow(hm, x, y).outflow.length > 0;
+}
+
+describe('generateDome', () => {
+  it('keeps every border cell at ocean height 0', () => {
+    const hm = generateDome(12, 9, mulberry32(42));
+    for (let x = 0; x < hm.width; x++) {
+      expect(heightAt(hm, x, 0)).toBe(0);
+      expect(heightAt(hm, x, hm.height - 1)).toBe(0);
+    }
+    for (let y = 0; y < hm.height; y++) {
+      expect(heightAt(hm, 0, y)).toBe(0);
+      expect(heightAt(hm, hm.width - 1, y)).toBe(0);
+    }
+  });
+
+  it('guarantees every interior cell drains to an edge (no trapped pits)', () => {
+    // The drainage invariant is the whole point of resolveDrainage: across many
+    // seeds and sizes, water released anywhere interior must reach an ocean edge.
+    for (const seed of [1, 2, 3, 99, 12345]) {
+      for (const size of [6, 10, 15]) {
+        const hm = generateDome(size, size, mulberry32(seed));
+        for (let y = 1; y < size - 1; y++) {
+          for (let x = 1; x < size - 1; x++) {
+            expect(drains(hm, x, y)).toBe(true);
+          }
+        }
+      }
+    }
+  });
+});
+
+describe('isOceanEdge', () => {
+  it('reports ocean sides and treats corners as ocean if either side is', () => {
+    const edges: Edges = { n: 'wall', e: 'ocean', s: 'ocean', w: 'wall' };
+    // 4x4 board. North (y=0) and west (x=0) are walls; east/south are ocean.
+    expect(isOceanEdge(edges, 2, 0, 4, 4)).toBe(false); // north wall
+    expect(isOceanEdge(edges, 0, 2, 4, 4)).toBe(false); // west wall
+    expect(isOceanEdge(edges, 3, 1, 4, 4)).toBe(true); // east ocean
+    expect(isOceanEdge(edges, 1, 3, 4, 4)).toBe(true); // south ocean
+    // NW corner touches two walls β†’ not ocean; SW corner touches south ocean.
+    expect(isOceanEdge(edges, 0, 0, 4, 4)).toBe(false);
+    expect(isOceanEdge(edges, 0, 3, 4, 4)).toBe(true);
+  });
+});
+
+describe('generateDome with walls', () => {
+  const canyon: Edges = { n: 'wall', e: 'ocean', s: 'wall', w: 'ocean' };
+
+  it('keeps ocean edges at 0 and stands wall edges up as high ground', () => {
+    const hm = generateDome(12, 12, mulberry32(3), canyon);
+    for (let y = 0; y < 12; y++) {
+      expect(heightAt(hm, 0, y)).toBe(0); // west ocean
+      expect(heightAt(hm, 11, y)).toBe(0); // east ocean
+    }
+    // The wall rows (excluding the ocean-corner ends) rise above 0.
+    for (let x = 1; x < 11; x++) {
+      expect(heightAt(hm, x, 0)).toBeGreaterThan(0); // north wall
+      expect(heightAt(hm, x, 11)).toBeGreaterThan(0); // south wall
+    }
+  });
+
+  it('still drains every interior cell β€” but only to ocean sides', () => {
+    for (const seed of [1, 2, 3, 99, 12345]) {
+      const hm = generateDome(12, 12, mulberry32(seed), canyon);
+      for (let y = 1; y < 11; y++) {
+        for (let x = 1; x < 11; x++) {
+          const { outflow } = simulateFlow(hm, x, y);
+          expect(outflow.length).toBeGreaterThan(0);
+          for (const o of outflow) {
+            expect(isOceanEdge(hm.edges, o.x, o.y, 12, 12)).toBe(true);
+          }
+        }
+      }
+    }
+  });
+});
+
+describe('resolveDrainage', () => {
+  it('raises a sealed interior pit until it drains', () => {
+    // A single deep pit walled off by higher ground; before filling it cannot
+    // drain, after filling it must.
+    const make = (): Heightmap => ({
+      width: 5,
+      height: 5,
+      edges: ALL_OCEAN,
+      data: Float32Array.from([
+        0, 0, 0, 0, 0,
+        0, 1, 1, 1, 0,
+        0, 1, 0, 1, 0,
+        0, 1, 1, 1, 0,
+        0, 0, 0, 0, 0,
+      ]),
+    });
+
+    const before = make();
+    expect(drains(before, 2, 2)).toBe(false);
+
+    const after = make();
+    resolveDrainage(after);
+    expect(heightAt(after, 2, 2)).toBeGreaterThan(0); // pit was raised
+    // Every interior cell now escapes.
+    for (let y = 1; y < 4; y++) {
+      for (let x = 1; x < 4; x++) expect(drains(after, x, y)).toBe(true);
+    }
+  });
+
+  it('leaves the ocean border untouched', () => {
+    const hm: Heightmap = {
+      width: 4,
+      height: 4,
+      edges: ALL_OCEAN,
+      data: Float32Array.from([
+        0, 0, 0, 0,
+        0, 1, 1, 0,
+        0, 1, 1, 0,
+        0, 0, 0, 0,
+      ]),
+    };
+    resolveDrainage(hm);
+    for (let x = 0; x < 4; x++) {
+      expect(heightAt(hm, x, 0)).toBe(0);
+      expect(heightAt(hm, x, 3)).toBe(0);
+    }
+  });
+});
diff --git a/src/model/heightmap.ts b/src/model/heightmap.ts
index 4d3b536..1130134 100644
--- a/src/model/heightmap.ts
+++ b/src/model/heightmap.ts
@@ -1,10 +1,26 @@
 import type { Rng } from '../rng';
 
+/** A board side is either ocean (water exits) or a mountain wall (water is blocked). */
+export type EdgeType = 'ocean' | 'wall';
+
+/** Per-side edge configuration. e.g. n+s wall, e+w ocean = a canyon. */
+export interface Edges {
+  n: EdgeType;
+  e: EdgeType;
+  s: EdgeType;
+  w: EdgeType;
+}
+
+/** The v1 default: all four sides ocean. */
+export const ALL_OCEAN: Edges = { n: 'ocean', e: 'ocean', s: 'ocean', w: 'ocean' };
+
 export interface Heightmap {
   width: number;
   height: number;
   /** Row-major elevations in [0, 1]. Ocean-border cells are exactly 0. */
   data: Float32Array;
+  /** Which borders are ocean (water exits) vs wall (blocks & rises as terrain). */
+  edges: Edges;
 }
 
 export function heightAt(hm: Heightmap, x: number, y: number): number {
@@ -12,13 +28,47 @@ export function heightAt(hm: Heightmap, x: number, y: number): number {
 }
 
 /**
- * Dome terrain: high in the middle, falling to 0 at every border cell,
- * perturbed by smooth value noise so the watersheds branch interestingly.
- *
- * NOTE: this v1 generator treats all four edges as ocean (height 0). Per-side
- * "mountain" walls (canyon configs) are a planned config-layer extension.
+ * Is border cell (x, y) on an ocean side β€” i.e. a place water can exit? Corner
+ * cells count as ocean if *either* adjoining side is ocean.
+ */
+export function isOceanEdge(
+  edges: Edges,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+): boolean {
+  return (
+    (y === 0 && edges.n === 'ocean') ||
+    (y === height - 1 && edges.s === 'ocean') ||
+    (x === 0 && edges.w === 'ocean') ||
+    (x === width - 1 && edges.e === 'ocean')
+  );
+}
+
+/** Cell distance to the nearest ocean edge (0 on an ocean edge, rising inland). */
+function distToOcean(x: number, y: number, width: number, height: number, edges: Edges): number {
+  let d = Infinity;
+  if (edges.n === 'ocean') d = Math.min(d, y);
+  if (edges.s === 'ocean') d = Math.min(d, height - 1 - y);
+  if (edges.w === 'ocean') d = Math.min(d, x);
+  if (edges.e === 'ocean') d = Math.min(d, width - 1 - x);
+  return Number.isFinite(d) ? d : 0;
+}
+
+/**
+ * Dome terrain, perturbed by smooth value noise so watersheds branch
+ * interestingly. Elevation rises with distance from the nearest *ocean* edge, so
+ * ocean sides fall to 0 while mountain-wall sides stand up as high ground β€”
+ * water runs off the walls toward the oceans (e.g. n+s walls make a canyon that
+ * drains east and west).
  */
-export function generateDome(width: number, height: number, rng: Rng): Heightmap {
+export function generateDome(
+  width: number,
+  height: number,
+  rng: Rng,
+  edges: Edges = ALL_OCEAN,
+): Heightmap {
   const data = new Float32Array(width * height);
 
   // Coarse value-noise lattice, bilinearly sampled across the board.
@@ -46,23 +96,21 @@ export function generateDome(width: number, height: number, rng: Rng): Heightmap
   };
 
   const amp = 0.6;
-  const maxD = Math.max(1, Math.min((width - 1) / 2, (height - 1) / 2));
   let peak = 0;
   for (let y = 0; y < height; y++) {
     for (let x = 0; x < width; x++) {
-      const dEdge = Math.min(x, y, width - 1 - x, height - 1 - y);
-      const dome = dEdge / maxD; // 0 at border, 1 at centre
-      const n = 1 + amp * (sampleNoise(x, y) * 2 - 1);
-      const v = Math.max(0, dome * n);
+      const dome = distToOcean(x, y, width, height, edges); // 0 on ocean edges
+      const n = 1 + amp * (sampleNoise(x, y) * 2 - 1); // n > 0, so v >= 0
+      const v = dome * n;
       data[y * width + x] = v;
       if (v > peak) peak = v;
     }
   }
 
-  // Normalise interior to [0, 1]; border stays 0.
+  // Normalise to [0, 1]; ocean edges stay 0, walls stand up as high ground.
   if (peak > 0) for (let i = 0; i < data.length; i++) data[i] /= peak;
 
-  const hm: Heightmap = { width, height, data };
+  const hm: Heightmap = { width, height, data, edges };
   resolveDrainage(hm);
   return hm;
 }
@@ -77,19 +125,22 @@ const DRAIN_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [
 /**
  * Priority-flood + epsilon depression filling (Barnes, Lehman & Mulla 2014).
  *
- * Guarantees every non-edge cell has a strictly-decreasing 4-connected path to
- * an ocean edge β€” so water released anywhere always drains out, never trapping
- * in an interior pit. Depressions are raised to their spill elevation (a natural
- * "lake" surface) and given an imperceptible gradient via `eps`; real peaks and
- * slopes (already above their spill) are left untouched.
+ * Guarantees every cell has a strictly-decreasing 4-connected path to an *ocean*
+ * edge β€” so water released anywhere always drains out to an ocean side, never
+ * trapping in an interior pit and never needing to cross a wall. Depressions are
+ * raised to their spill elevation (a natural "lake" surface) and given an
+ * imperceptible gradient via `eps`; real peaks and slopes (already above their
+ * spill) are left untouched.
  */
 export function resolveDrainage(hm: Heightmap, eps = 1e-4): void {
-  const { width, height, data } = hm;
+  const { width, height, data, edges } = hm;
   const closed = new Uint8Array(width * height);
   const heap = new MinHeap();
 
-  // Seed the flood from every border cell β€” these are the ocean spill points.
+  // Seed the flood from ocean-edge cells only β€” these are the spill points.
+  // Wall edges are treated as ordinary (floodable) terrain and rise like it.
   const seed = (x: number, y: number): void => {
+    if (!isOceanEdge(edges, x, y, width, height)) return;
     const i = y * width + x;
     closed[i] = 1;
     heap.push(i, data[i]);
diff --git a/src/model/puzzle.test.ts b/src/model/puzzle.test.ts
new file mode 100644
index 0000000..745ecbc
--- /dev/null
+++ b/src/model/puzzle.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect } from 'vitest';
+import {
+  generatePuzzle,
+  isSolutionSpring,
+  encodeConfig,
+  decodeConfig,
+  normalizeConfig,
+  randomConfig,
+  SIZE_MIN,
+  SIZE_MAX,
+  SPRINGS_MIN,
+  SPRINGS_MAX,
+} from './puzzle';
+import type { Config } from './puzzle';
+import { simulateFlows } from './flow';
+import { isOceanEdge, ALL_OCEAN } from './heightmap';
+import type { Edges } from './heightmap';
+
+const SIZE = 12;
+const COUNT = 3;
+const CANYON: Edges = { n: 'wall', e: 'ocean', s: 'wall', w: 'ocean' };
+
+const cfg = (over: Partial<Config> = {}): Config => ({
+  size: SIZE,
+  springCount: COUNT,
+  edges: ALL_OCEAN,
+  terrainSeed: 4242,
+  ...over,
+});
+
+describe('generatePuzzle', () => {
+  it('is deterministic in its config', () => {
+    const a = generatePuzzle(cfg());
+    const b = generatePuzzle(cfg());
+    expect(a.solution).toEqual(b.solution);
+    expect(Array.from(a.target)).toEqual(Array.from(b.target));
+  });
+
+  it('honours the requested size, edges, and spring count', () => {
+    for (const seed of [1, 7, 55, 1000, 999999]) {
+      const p = generatePuzzle(cfg({ size: 10, springCount: 4, edges: CANYON, terrainSeed: seed }));
+      expect(p.width).toBe(10);
+      expect(p.height).toBe(10);
+      expect(p.edges).toEqual(CANYON);
+      expect(p.solution).toHaveLength(4);
+
+      const seen = new Set<string>();
+      for (const s of p.solution) {
+        expect(s.x).toBeGreaterThan(0);
+        expect(s.y).toBeGreaterThan(0);
+        expect(s.x).toBeLessThan(9);
+        expect(s.y).toBeLessThan(9);
+        seen.add(`${s.x},${s.y}`);
+      }
+      expect(seen.size).toBe(4); // distinct
+    }
+  });
+
+  it("target marks exactly the solution flow's wet ocean-edge cells", () => {
+    for (const seed of [1, 7, 55, 1000, 999999]) {
+      const p = generatePuzzle(cfg({ edges: CANYON, terrainSeed: seed }));
+      const flow = simulateFlows(p.hm, p.solution);
+      for (let y = 0; y < SIZE; y++) {
+        for (let x = 0; x < SIZE; x++) {
+          const i = y * SIZE + x;
+          const shouldBeWet = isOceanEdge(p.edges, x, y, SIZE, SIZE) && flow.wet[i] === 1;
+          expect(p.target[i] === 1).toBe(shouldBeWet);
+        }
+      }
+    }
+  });
+
+  it('produces a non-empty target (the puzzle is solvable/visible)', () => {
+    for (const seed of [1, 7, 55, 1000, 999999]) {
+      const p = generatePuzzle(cfg({ terrainSeed: seed }));
+      expect(p.targetOutflow.length).toBeGreaterThan(0);
+    }
+  });
+});
+
+describe('isSolutionSpring', () => {
+  it('recognises solution cells and rejects others', () => {
+    const p = generatePuzzle(cfg({ terrainSeed: 321 }));
+    for (const s of p.solution) expect(isSolutionSpring(p, s)).toBe(true);
+
+    const solutionKeys = new Set(p.solution.map((s) => `${s.x},${s.y}`));
+    let checked = false;
+    for (let y = 1; y < SIZE - 1 && !checked; y++) {
+      for (let x = 1; x < SIZE - 1; x++) {
+        if (!solutionKeys.has(`${x},${y}`)) {
+          expect(isSolutionSpring(p, { x, y })).toBe(false);
+          checked = true;
+          break;
+        }
+      }
+    }
+    expect(checked).toBe(true);
+  });
+});
+
+describe('config code', () => {
+  it('round-trips through encode/decode', () => {
+    const configs: Config[] = [
+      cfg(),
+      cfg({ size: 5, springCount: 1, edges: { n: 'wall', e: 'wall', s: 'wall', w: 'ocean' } }),
+      cfg({ size: 20, springCount: 6, edges: CANYON, terrainSeed: 0xffffffff }),
+      cfg({ terrainSeed: 0 }),
+    ];
+    for (const c of configs) {
+      const code = encodeConfig(c);
+      expect(code).toHaveLength(8);
+      expect(decodeConfig(code)).toEqual(normalizeConfig(c));
+    }
+  });
+
+  it('returns null for codes of the wrong length or alphabet', () => {
+    for (const bad of ['', 'abc', 'AAAAAAA', 'AAAAAAAAA', '!!!!!!!!', 'BQAr3k f']) {
+      expect(decodeConfig(bad)).toBeNull();
+    }
+  });
+
+  it('normalises out-of-range and all-wall configs', () => {
+    const clamped = normalizeConfig(
+      cfg({ size: 999, springCount: 999, edges: { n: 'wall', e: 'wall', s: 'wall', w: 'wall' } }),
+    );
+    expect(clamped.size).toBe(SIZE_MAX);
+    expect(clamped.springCount).toBe(SPRINGS_MAX);
+    // All-wall is illegal (no exit) β†’ at least one side forced ocean.
+    const sides = [clamped.edges.n, clamped.edges.e, clamped.edges.s, clamped.edges.w];
+    expect(sides).toContain('ocean');
+
+    const tiny = normalizeConfig(cfg({ size: -3, springCount: 0 }));
+    expect(tiny.size).toBe(SIZE_MIN);
+    expect(tiny.springCount).toBe(SPRINGS_MIN);
+  });
+});
+
+describe('randomConfig', () => {
+  it('stays within bounds with at least one ocean side', () => {
+    for (let i = 0; i < 200; i++) {
+      const c = randomConfig();
+      expect(c.size).toBeGreaterThanOrEqual(SIZE_MIN);
+      expect(c.size).toBeLessThanOrEqual(SIZE_MAX);
+      expect(c.springCount).toBeGreaterThanOrEqual(SPRINGS_MIN);
+      expect(c.springCount).toBeLessThanOrEqual(SPRINGS_MAX);
+      const sides = [c.edges.n, c.edges.e, c.edges.s, c.edges.w];
+      expect(sides.some((s) => s === 'ocean')).toBe(true);
+      // A random config must always be buildable into a solvable puzzle.
+      expect(generatePuzzle(c).solution).toHaveLength(c.springCount);
+    }
+  });
+});
diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts
new file mode 100644
index 0000000..9b8a49d
--- /dev/null
+++ b/src/model/puzzle.ts
@@ -0,0 +1,228 @@
+import { mulberry32 } from '../rng';
+import type { Rng } from '../rng';
+import { generateDome, heightAt } from './heightmap';
+import type { Edges, EdgeType, Heightmap } from './heightmap';
+import { simulateFlows } from './flow';
+import type { Cell } from './flow';
+
+/**
+ * Everything that defines a board. A `Config` maps 1:1 to a shareable {@link
+ * encodeConfig | code}: same config β†’ same board, and every axis is a knob the
+ * UI exposes.
+ */
+export interface Config {
+  /** Square board edge length, in cells. */
+  size: number;
+  /** How many hidden springs the player must find. */
+  springCount: number;
+  /** Per-side edge types (ocean vs wall). */
+  edges: Edges;
+  /** Seed for the terrain noise + spring placement (given the other axes). */
+  terrainSeed: number;
+}
+
+export const SIZE_MIN = 5;
+export const SIZE_MAX = 20;
+export const SPRINGS_MIN = 1;
+export const SPRINGS_MAX = 6;
+
+/**
+ * A solvable board (Reading A): terrain + a target edge signature are given, and
+ * the player must rediscover the hidden spring *positions* that produce it.
+ */
+export interface Puzzle {
+  config: Config;
+  hm: Heightmap;
+  width: number;
+  height: number;
+  /** Per-side edge configuration (ocean vs wall) for this board. */
+  edges: Edges;
+  /** Hidden spring positions the player is hunting for. Order irrelevant. */
+  solution: Cell[];
+  /** 1 for every border cell water must reach in the solution, row-major. */
+  target: Uint8Array;
+  /** The target's wet border cells, for rendering the goal. */
+  targetOutflow: Cell[];
+}
+
+/**
+ * Build a puzzle from a config. The terrain seed drives terrain + spring
+ * placement, so a board is fully reproducible from its config (hence its code).
+ *
+ * Springs are drawn from interior land cells, kept a minimum gap apart so their
+ * watersheds stay distinct, and re-rolled until they yield a reasonably rich
+ * edge signature β€” a larger target is less ambiguous to hunt for.
+ */
+export function generatePuzzle(config: Config): Puzzle {
+  const norm = normalizeConfig(config);
+  const { size, springCount, edges, terrainSeed } = norm;
+  const rng = mulberry32(terrainSeed);
+  const hm = generateDome(size, size, rng, edges);
+
+  // Candidates: interior (non-edge) land cells. After drainage resolution every
+  // interior cell has height > 0 and a strictly-decreasing path to an ocean edge.
+  const candidates: Cell[] = [];
+  for (let y = 1; y < size - 1; y++) {
+    for (let x = 1; x < size - 1; x++) {
+      if (heightAt(hm, x, y) > 0) candidates.push({ x, y });
+    }
+  }
+
+  const wanted = Math.max(3, springCount); // prefer a signature at least this wide
+  let best: { solution: Cell[]; outflow: Cell[] } | null = null;
+
+  for (let attempt = 0; attempt < 128; attempt++) {
+    // Relax the spacing requirement if the board is too cramped to satisfy it.
+    const minGap = attempt < 96 ? 3 : attempt < 112 ? 2 : 1;
+    const picked = pickSprings(candidates, springCount, rng, minGap);
+    if (picked.length < springCount) continue;
+
+    const flow = simulateFlows(hm, picked);
+    if (flow.outflow.length === 0) continue; // must reach an ocean edge
+
+    if (!best || flow.outflow.length > best.outflow.length) {
+      best = { solution: picked, outflow: flow.outflow };
+    }
+    if (flow.outflow.length >= wanted) break; // rich enough β€” good to go
+  }
+
+  // `best` is set once any attempt yields springs; normalizeConfig guarantees the
+  // board has at least `springCount` interior candidates, so that always happens.
+  const solution = best!.solution;
+  const targetOutflow = best!.outflow;
+  const target = borderSignature(targetOutflow, size, size);
+
+  return { config: norm, hm, width: size, height: size, edges, solution, target, targetOutflow };
+}
+
+/** Is `cell` one of the puzzle's hidden spring positions? */
+export function isSolutionSpring(puzzle: Puzzle, cell: Cell): boolean {
+  return puzzle.solution.some((s) => s.x === cell.x && s.y === cell.y);
+}
+
+// ---------------------------------------------------------------------------
+// Config: bounds, randomisation, and the shareable code.
+// ---------------------------------------------------------------------------
+
+const clampInt = (v: number, lo: number, hi: number): number =>
+  Math.min(hi, Math.max(lo, Math.round(v)));
+
+/** Clamp every axis into range and guarantee at least one ocean side. */
+export function normalizeConfig(c: Config): Config {
+  const size = clampInt(c.size, SIZE_MIN, SIZE_MAX);
+  const interior = (size - 2) * (size - 2);
+  const springCount = clampInt(c.springCount, SPRINGS_MIN, Math.min(SPRINGS_MAX, interior));
+  return { size, springCount, edges: ensureOcean(c.edges), terrainSeed: c.terrainSeed >>> 0 };
+}
+
+/** A wholly random board within sensible bounds. */
+export function randomConfig(): Config {
+  return normalizeConfig({
+    size: 8 + Math.floor(Math.random() * 5) * 2, // 8,10,12,14,16
+    springCount: 2 + Math.floor(Math.random() * 3), // 2,3,4
+    edges: rollEdges(Math.random),
+    terrainSeed: (Math.random() * 2 ** 32) >>> 0,
+  });
+}
+
+// The config packs into 44 bits β€” size(5) Β· springs(3) Β· edges(4) Β· seed(32) β€”
+// which we emit as an 8-char base64url string so the axes aren't legible at a
+// glance. It's obfuscation, not encryption: fully reversible, no secrets.
+const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
+const CODE_LEN = 8;
+
+/** A compact, reversible code for a config, e.g. "BQAr3kf9". */
+export function encodeConfig(c: Config): string {
+  const n = normalizeConfig(c);
+  let v =
+    BigInt(n.terrainSeed >>> 0) |
+    (BigInt(edgesToMask(n.edges)) << 32n) |
+    (BigInt(n.springCount) << 36n) |
+    (BigInt(n.size) << 39n);
+  let out = '';
+  for (let i = 0; i < CODE_LEN; i++) {
+    out = B64[Number(v & 63n)] + out;
+    v >>= 6n;
+  }
+  return out;
+}
+
+/** Parse a config code, or null if it isn't well-formed. */
+export function decodeConfig(code: string): Config | null {
+  const s = code.trim();
+  if (s.length !== CODE_LEN) return null;
+  let v = 0n;
+  for (const ch of s) {
+    const d = B64.indexOf(ch);
+    if (d < 0) return null;
+    v = (v << 6n) | BigInt(d);
+  }
+  return normalizeConfig({
+    size: Number((v >> 39n) & 0x1fn),
+    springCount: Number((v >> 36n) & 0x7n),
+    edges: maskToEdges(Number((v >> 32n) & 0xfn)),
+    terrainSeed: Number(v & 0xffffffffn) >>> 0,
+  });
+}
+
+/** Bit per side (1 = wall): n=8, e=4, s=2, w=1. */
+function edgesToMask(e: Edges): number {
+  return (
+    (e.n === 'wall' ? 8 : 0) |
+    (e.e === 'wall' ? 4 : 0) |
+    (e.s === 'wall' ? 2 : 0) |
+    (e.w === 'wall' ? 1 : 0)
+  );
+}
+
+function maskToEdges(mask: number): Edges {
+  const side = (bit: number): EdgeType => ((mask >> bit) & 1 ? 'wall' : 'ocean');
+  return { n: side(3), e: side(2), s: side(1), w: side(0) };
+}
+
+/** If every side is a wall, water could never exit β€” force the west side ocean. */
+function ensureOcean(edges: Edges): Edges {
+  const allWall =
+    edges.n === 'wall' && edges.e === 'wall' && edges.s === 'wall' && edges.w === 'wall';
+  return allWall ? { ...edges, w: 'ocean' } : { ...edges };
+}
+
+/**
+ * Roll a per-side edge configuration. Each side is a wall ~35% of the time, with
+ * at least one ocean side kept so water can always exit somewhere.
+ */
+function rollEdges(rng: Rng): Edges {
+  const pick = (): EdgeType => (rng() < 0.35 ? 'wall' : 'ocean');
+  return ensureOcean({ n: pick(), e: pick(), s: pick(), w: pick() });
+}
+
+/**
+ * Sample `k` distinct candidates that are pairwise at least `minGap` apart
+ * (Chebyshev distance). Returns fewer than `k` only if the spacing can't be met.
+ */
+function pickSprings(candidates: Cell[], k: number, rng: Rng, minGap: number): Cell[] {
+  const pool = candidates.slice();
+  for (let i = pool.length - 1; i > 0; i--) {
+    const j = Math.floor(rng() * (i + 1));
+    [pool[i], pool[j]] = [pool[j], pool[i]];
+  }
+
+  const chosen: Cell[] = [];
+  for (const c of pool) {
+    const spaced = chosen.every(
+      (o) => Math.max(Math.abs(o.x - c.x), Math.abs(o.y - c.y)) >= minGap,
+    );
+    if (spaced) {
+      chosen.push(c);
+      if (chosen.length === k) break;
+    }
+  }
+  return chosen;
+}
+
+/** Mark the wet border cells of a flow as a row-major signature mask. */
+function borderSignature(outflow: Cell[], width: number, height: number): Uint8Array {
+  const sig = new Uint8Array(width * height);
+  for (const c of outflow) sig[c.y * width + c.x] = 1;
+  return sig;
+}
diff --git a/src/render/renderer.ts b/src/render/renderer.ts
index b8608ad..10736ac 100644
--- a/src/render/renderer.ts
+++ b/src/render/renderer.ts
@@ -1,7 +1,17 @@
 import type { Heightmap } from '../model/heightmap';
-import { heightAt } from '../model/heightmap';
+import { heightAt, isOceanEdge } from '../model/heightmap';
 import type { Cell, FlowResult } from '../model/flow';
 
+/** Puzzle-mode overlay: the goal edges plus the player's springs. */
+export interface PuzzleView {
+  /** 1 for every border cell the solution wets, row-major. */
+  target: Uint8Array;
+  /** Locked, correctly-found springs, in find order (labelled 1..k). */
+  found: Cell[];
+  /** The current transient exploration tap, if any. */
+  probe: Cell | null;
+}
+
 /**
  * Canvas2D renderer for small boards (one filled rect per cell). Deliberately
  * decoupled from the model so a tiled / WebGL renderer can replace it for
@@ -26,7 +36,7 @@ export class Renderer {
     return { x, y };
   }
 
-  render(hm: Heightmap, flow: FlowResult | null): void {
+  render(hm: Heightmap, flow: FlowResult | null, view?: PuzzleView | null): void {
     const dpr = window.devicePixelRatio || 1;
     const maxPx = Math.min(window.innerWidth - 32, 560);
     this.cell = Math.max(1, Math.floor(maxPx / hm.width));
@@ -49,8 +59,8 @@ export class Renderer {
       }
     }
 
+    // Wet cells (union of the current springs' flow).
     if (flow) {
-      // Wet cells.
       ctx.fillStyle = 'rgba(40,120,255,0.55)';
       for (let y = 0; y < hm.height; y++) {
         for (let x = 0; x < hm.width; x++) {
@@ -59,12 +69,31 @@ export class Renderer {
           }
         }
       }
-      // Outflow markers.
+    }
+
+    if (view) {
+      this.drawTarget(hm, view.target, flow);
+      // Locked, found springs: numbered so order-of-discovery reads clearly.
+      view.found.forEach((s, i) => {
+        ctx.fillStyle = '#34c759';
+        this.dot(s.x, s.y, this.cell * 0.3, 5);
+        ctx.fillStyle = '#06210d';
+        ctx.font = `${Math.max(9, Math.floor(this.cell * 0.4))}px system-ui, sans-serif`;
+        ctx.textAlign = 'center';
+        ctx.textBaseline = 'middle';
+        ctx.fillText(String(i + 1), (s.x + 0.5) * this.cell, (s.y + 0.55) * this.cell);
+      });
+      // Transient exploration tap.
+      if (view.probe) {
+        ctx.fillStyle = '#ff3b30';
+        this.dot(view.probe.x, view.probe.y, this.cell * 0.28, 4);
+      }
+    } else if (flow) {
+      // Sandbox fallback: plain outflow + spring markers.
       ctx.fillStyle = '#ffe14d';
       for (const o of flow.outflow) this.dot(o.x, o.y, this.cell * 0.18, 3);
-      // Spring marker.
       ctx.fillStyle = '#ff3b30';
-      this.dot(flow.spring.x, flow.spring.y, this.cell * 0.28, 4);
+      for (const s of flow.springs) this.dot(s.x, s.y, this.cell * 0.28, 4);
     }
 
     // Grid lines (small boards only).
@@ -86,6 +115,41 @@ export class Renderer {
     }
   }
 
+  /**
+   * Border feedback. Gold outline = a goal edge cell the solution wets. When
+   * the current flow reaches it the outline fills green (satisfied); a wet
+   * border cell that is *not* a goal fills red (overflow β€” a wrong spring).
+   */
+  private drawTarget(hm: Heightmap, target: Uint8Array, flow: FlowResult | null): void {
+    const ctx = this.ctx;
+    const c = this.cell;
+    const inset = Math.max(1.5, c * 0.12);
+    ctx.lineWidth = Math.max(2, c * 0.14);
+
+    for (let y = 0; y < hm.height; y++) {
+      for (let x = 0; x < hm.width; x++) {
+        // Only ocean edges carry goal/overflow feedback β€” water pooling against
+        // a wall is expected, not a mistake.
+        if (!isOceanEdge(hm.edges, x, y, hm.width, hm.height)) continue;
+        const i = y * hm.width + x;
+        const goal = target[i] === 1;
+        const wet = flow ? flow.wet[i] === 1 : false;
+
+        if (goal) {
+          ctx.strokeStyle = '#ffd60a';
+          ctx.strokeRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+          if (wet) {
+            ctx.fillStyle = 'rgba(52,199,89,0.55)';
+            ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+          }
+        } else if (wet) {
+          ctx.fillStyle = 'rgba(255,59,48,0.5)';
+          ctx.fillRect(x * c + inset, y * c + inset, c - inset * 2, c - inset * 2);
+        }
+      }
+    }
+  }
+
   private dot(cx: number, cy: number, r: number, min: number): void {
     const ctx = this.ctx;
     ctx.beginPath();
diff --git a/src/style.css b/src/style.css
index fe9fdc9..126914c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -39,14 +39,7 @@ h1 {
   font-size: 1.6rem;
 }
 
-.seed {
-  font-size: 0.8rem;
-  opacity: 0.55;
-  font-variant-numeric: tabular-nums;
-}
-
 button {
-  margin-left: auto;
   padding: 6px 14px;
   border: 0;
   border-radius: 8px;
@@ -60,12 +53,101 @@ button:hover {
   background: #3b7bf0;
 }
 
+header button {
+  margin-left: auto;
+}
+
+.config {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: 10px 16px;
+  width: 100%;
+  padding: 10px 12px;
+  box-sizing: border-box;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.04);
+  font-size: 0.8rem;
+}
+
+.ctl {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  opacity: 0.85;
+}
+
+.ctl .val {
+  opacity: 0.6;
+  font-variant-numeric: tabular-nums;
+}
+
+.config input[type='number'],
+.config input[type='text'] {
+  padding: 4px 6px;
+  border: 1px solid rgba(255, 255, 255, 0.15);
+  border-radius: 6px;
+  background: #0d1017;
+  color: #e7ecf3;
+  font: inherit;
+}
+
+.config input[type='number'] {
+  width: 4.5em;
+}
+
+.config .code {
+  flex: 1 1 12em;
+}
+
+.config .code input {
+  width: 100%;
+  box-sizing: border-box;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  letter-spacing: 0.03em;
+}
+
+.seedrow {
+  display: flex;
+  gap: 4px;
+}
+
+.seedrow button {
+  padding: 4px 8px;
+}
+
+.edges {
+  border: 1px solid rgba(255, 255, 255, 0.15);
+  border-radius: 6px;
+  padding: 2px 8px 6px;
+  margin: 0;
+}
+
+.edges legend {
+  padding: 0 4px;
+  opacity: 0.6;
+}
+
+.edges label {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  margin-right: 6px;
+}
+
 canvas {
   border-radius: 8px;
   box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
   touch-action: manipulation;
 }
 
+.status {
+  margin: 0;
+  font-size: 1rem;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+
 .hint {
   margin: 0;
   font-size: 0.85rem;