fuzzyworld

basin / commit

Fix board-generation hang on sparse boards; add Reach axis; keep springs off shore cells

36e6a8b94ff115b424b376d5d252b97d9c686f0e · Pavel Safronov · 2026-07-01 23:26:33 -0700 · parents 3465f976007b

Fix board-generation hang on sparse boards; add Reach axis; keep springs off shore cells

Heavy erosion could leave too few land cells for springCount, crashing
generation inside the worker and hanging the "Generating…" modal forever.
Now backfills candidates and backs off effective erosion (deterministically)
when a seed would wash away too much land.

Also adds a "Reach" config axis (0-15) biasing spring placement toward the
shoreline or the peak, and excludes shore cells (where the renderer draws the
gold target outline) from spring candidacy so a spring can no longer double
as its own giveaway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
 index.html               |   7 ++
 readme.md                |  34 +++++++---
 src/main.ts              |  17 ++++-
 src/model/puzzle.test.ts | 111 +++++++++++++++++++++++++++++++-
 src/model/puzzle.ts      | 163 +++++++++++++++++++++++++++++++++++++++--------
 5 files changed, 294 insertions(+), 38 deletions(-)

diff --git a/index.html b/index.html
index 81e6f2f..bd9a190 100644
--- a/index.html
+++ b/index.html
@@ -45,6 +45,13 @@
             </span>
             <input type="range" id="cfg-erosion" min="0" max="15" step="1" />
           </label>
+          <label class="ctl" title="How far from the shore springs are drawn from. Low favours land just above the waterline; high favours high ground near the peak.">
+            <span class="ctl-head">
+              <span>Reach <span id="cfg-reach-val" class="val"></span></span>
+              <button type="button" class="lock" data-lock="reach" aria-pressed="false">🔓</button>
+            </span>
+            <input type="range" id="cfg-reach" min="0" max="15" step="1" />
+          </label>
           <label class="ctl">
             <span class="ctl-head">
               <span>Terrain</span>
diff --git a/readme.md b/readme.md
index be31e3a..576b940 100644
--- a/readme.md
+++ b/readme.md
@@ -15,7 +15,8 @@ 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).
+signature**, shown as gold outlines on the *shore* — the land cells immediately
+inland of each wet ocean edge, rather than the ocean cells themselves.
 
 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.
@@ -26,7 +27,16 @@ The terrain can also be **aged**: an *erosion* amount simulates how much
 weathering happened before you arrived. At 0 the board is a pristine dome; turn
 it up and simulated hydraulic erosion carves grooves and channels, ragging the
 coastline and moving where water finally reaches the sea — so a heavily-aged
-board reads a visibly different edge signature from its pristine self.
+board reads a visibly different edge signature from its pristine self. Erosion
+is self-limiting: if a particular seed would wash away too much standing land,
+generation automatically backs off the effective aging for that board so it
+never degenerates into an almost-all-water board.
+
+**Reach** controls how far from the shore the hidden springs are drawn from:
+low values favour land just above the waterline, high values favour high
+ground near the peak, and the midpoint has no preference (the original
+behaviour). This only shapes which legible answer generation reaches for — it
+never affects the unambiguous-solution guarantee below.
 
 ## How to play
 
@@ -41,16 +51,22 @@ gold-edge hint always pins down a single answer (no redundant or interchangeable
 springs). Boards that can't be made unique fall back to the richest signature
 found, so a board is always produced.
 
+Springs also never sit on a shore cell — the gold outline never doubles as a
+giveaway for one of the hidden positions. (Only an extreme combination, like a
+5×5 board with 6 springs, can make this strictly impossible; generation falls
+back gracefully there rather than failing.)
+
 ## Configuring & sharing boards
 
 Every axis of a board is a control in the panel: **size**, **spring count**, which
-sides are **walls**, the **aging** (erosion) amount, and the **terrain seed**. All
-of it is captured in a single shareable **code** — an 8-character token (e.g.
-`B0B0bgTL`) that packs the five axes into 48 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. Older codes
-predate the aging axis and simply decode to a pristine (un-eroded) board.
+sides are **walls**, the **aging** (erosion) amount, **reach**, and the **terrain
+seed**. All of it is captured in a single shareable **code** — a 9-character token
+(e.g. `B0B0bgTL9`) that packs the six axes into 52 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 reach axis grew the code by a character, so pre-reach 8-character codes no
+longer decode — a stale shared link just rolls a fresh board instead.
 
 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
diff --git a/src/main.ts b/src/main.ts
index 5afee59..d0194de 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -42,6 +42,8 @@ const sizeVal = $<HTMLElement>('#cfg-size-val');
 const springsInput = $<HTMLInputElement>('#cfg-springs');
 const erosionInput = $<HTMLInputElement>('#cfg-erosion');
 const erosionVal = $<HTMLElement>('#cfg-erosion-val');
+const reachInput = $<HTMLInputElement>('#cfg-reach');
+const reachVal = $<HTMLElement>('#cfg-reach-val');
 const wall = {
   n: $<HTMLInputElement>('#cfg-wall-n'),
   e: $<HTMLInputElement>('#cfg-wall-e'),
@@ -62,6 +64,8 @@ function syncControls(): void {
   springsInput.value = String(config.springCount);
   erosionInput.value = String(config.erosion);
   erosionVal.textContent = agingLabel(config.erosion);
+  reachInput.value = String(config.reach);
+  reachVal.textContent = reachLabel(config.reach);
   wall.n.checked = config.edges.n === 'wall';
   wall.e.checked = config.edges.e === 'wall';
   wall.s.checked = config.edges.s === 'wall';
@@ -87,12 +91,13 @@ const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean');
 
 // Which config axes are pinned against Randomize. Session-only: locks aren't part
 // of a board (the code encodes the config, not what you froze while rolling).
-type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'terrainSeed';
+type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'reach' | 'terrainSeed';
 const locks: Record<LockAxis, boolean> = {
   size: false,
   springCount: false,
   edges: false,
   erosion: false,
+  reach: false,
   terrainSeed: false,
 };
 
@@ -121,6 +126,7 @@ function randomizeUnlocked(): Config {
     springCount: locks.springCount ? config.springCount : r.springCount,
     edges: locks.edges ? { ...config.edges } : r.edges,
     erosion: locks.erosion ? config.erosion : r.erosion,
+    reach: locks.reach ? config.reach : r.reach,
     terrainSeed: locks.terrainSeed ? config.terrainSeed : r.terrainSeed,
   };
 }
@@ -131,6 +137,12 @@ function agingLabel(n: number): string {
   return `${n} · ${word}`;
 }
 
+/** Human tag for a reach level, so the slider reads as more than a number. */
+function reachLabel(n: number): string {
+  const word = n <= 3 ? 'shore' : n <= 11 ? 'mixed' : 'peaks';
+  return `${n} · ${word}`;
+}
+
 // 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) }));
@@ -141,6 +153,9 @@ springsInput.addEventListener('change', () =>
 // on release.
 erosionInput.addEventListener('input', () => (erosionVal.textContent = agingLabel(Number(erosionInput.value))));
 erosionInput.addEventListener('change', () => apply({ ...config, erosion: Number(erosionInput.value) }));
+// Live-update the reach label while dragging, but only rebuild on release.
+reachInput.addEventListener('input', () => (reachVal.textContent = reachLabel(Number(reachInput.value))));
+reachInput.addEventListener('change', () => apply({ ...config, reach: Number(reachInput.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) } }),
diff --git a/src/model/puzzle.test.ts b/src/model/puzzle.test.ts
index bd7383b..faaf497 100644
--- a/src/model/puzzle.test.ts
+++ b/src/model/puzzle.test.ts
@@ -11,10 +11,13 @@ import {
   SPRINGS_MIN,
   SPRINGS_MAX,
   EROSION_MAX,
+  REACH_MIN,
+  REACH_MAX,
+  REACH_DEFAULT,
 } from './puzzle';
 import type { Config } from './puzzle';
 import { simulateFlows } from './flow';
-import { isOceanEdge, ALL_OCEAN } from './heightmap';
+import { heightAt, isOceanEdge, ALL_OCEAN } from './heightmap';
 import type { Edges } from './heightmap';
 
 const SIZE = 12;
@@ -26,6 +29,7 @@ const cfg = (over: Partial<Config> = {}): Config => ({
   springCount: COUNT,
   edges: ALL_OCEAN,
   erosion: 0,
+  reach: REACH_DEFAULT,
   terrainSeed: 4242,
   ...over,
 });
@@ -143,17 +147,21 @@ describe('config code', () => {
     ];
     for (const c of configs) {
       const code = encodeConfig(c);
-      expect(code).toHaveLength(8);
+      expect(code).toHaveLength(9);
       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']) {
+    for (const bad of ['', 'abc', 'AAAAAAAA', 'AAAAAAAAAA', '!!!!!!!!!', 'BQAr3k9 f']) {
       expect(decodeConfig(bad)).toBeNull();
     }
   });
 
+  it('rejects the pre-reach 8-char code format (breaking format bump)', () => {
+    expect(decodeConfig('BQAr3kf9')).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' } }),
@@ -177,6 +185,14 @@ describe('config code', () => {
     delete (legacy as Partial<Config>).erosion;
     expect(normalizeConfig(legacy).erosion).toBe(0);
   });
+
+  it('clamps reach into range and defaults a missing value to REACH_DEFAULT', () => {
+    expect(normalizeConfig(cfg({ reach: 999 })).reach).toBe(REACH_MAX);
+    expect(normalizeConfig(cfg({ reach: -5 })).reach).toBe(REACH_MIN);
+    const legacy = cfg();
+    delete (legacy as Partial<Config>).reach;
+    expect(normalizeConfig(legacy).reach).toBe(REACH_DEFAULT);
+  });
 });
 
 describe('erosion (aging)', () => {
@@ -211,6 +227,95 @@ describe('erosion (aging)', () => {
   });
 });
 
+describe('reach', () => {
+  it('biases spring elevation toward shore (low) or peak (high)', () => {
+    // Average solution height across many seeds should rise monotonically-ish
+    // from low reach (near shore) to high reach (near peak).
+    const meanHeight = (reach: number): number => {
+      let total = 0;
+      let n = 0;
+      for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
+        const p = generatePuzzle(cfg({ size: 16, springCount: 3, reach, terrainSeed: seed }));
+        for (const s of p.solution) {
+          total += heightAt(p.hm, s.x, s.y);
+          n++;
+        }
+      }
+      return total / n;
+    };
+
+    const low = meanHeight(REACH_MIN);
+    const high = meanHeight(REACH_MAX);
+    expect(low).toBeLessThan(high);
+  });
+
+  it('still produces a full, distinct spring set at both extremes', () => {
+    for (const reach of [REACH_MIN, REACH_MAX]) {
+      for (const seed of [1, 42, 12345]) {
+        const p = generatePuzzle(cfg({ size: 14, springCount: 4, reach, terrainSeed: seed }));
+        expect(p.solution).toHaveLength(4);
+        expect(new Set(p.solution.map((c) => `${c.x},${c.y}`)).size).toBe(4);
+      }
+    }
+  });
+});
+
+describe('springs never land on a shore (target-indicator) cell', () => {
+  const isShore = (c: { x: number; y: number }, edges: Edges, size: number): boolean =>
+    ([[c.x - 1, c.y], [c.x + 1, c.y], [c.x, c.y - 1], [c.x, c.y + 1]] as const).some(([nx, ny]) =>
+      isOceanEdge(edges, nx, ny, size, size),
+    );
+
+  it('holds across realistic (randomConfig-range) boards, including reach 0', () => {
+    // Reach 0 (shore-biased) is exactly the setting most likely to collide
+    // with the gold target indicator, which the renderer also draws on shore
+    // cells — so this is the sharpest check that the exclusion is working.
+    for (const seed of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
+      const size = 8 + (seed % 5) * 2; // 8..16
+      const p = generatePuzzle(cfg({ size, springCount: 4, reach: REACH_MIN, terrainSeed: seed }));
+      for (const s of p.solution) expect(isShore(s, p.edges, size)).toBe(false);
+    }
+  });
+
+  it('holds at every reach setting on a mid-sized board', () => {
+    for (let reach = REACH_MIN; reach <= REACH_MAX; reach++) {
+      const p = generatePuzzle(cfg({ size: 16, springCount: 3, reach, terrainSeed: 777 }));
+      for (const s of p.solution) expect(isShore(s, p.edges, 16)).toBe(false);
+    }
+  });
+
+  it('still fills springCount even when the non-shore interior is too small to avoid it', () => {
+    // size 5 → interior 3x3, only the centre cell is non-shore; springCount 6
+    // structurally can't be satisfied without some shore cells.
+    const p = generatePuzzle(cfg({ size: 5, springCount: 6, terrainSeed: 1 }));
+    expect(p.solution).toHaveLength(6);
+    expect(new Set(p.solution.map((c) => `${c.x},${c.y}`)).size).toBe(6);
+  });
+});
+
+describe('land sparsity', () => {
+  it('never leaves a board where land candidates fall short of springCount', () => {
+    // Heavy erosion can flatten most of the interior; generation must still
+    // land on a full, distinct spring set rather than crashing or degenerating.
+    for (const size of [5, 8, 12]) {
+      for (const springCount of [1, 4, 6]) {
+        for (const seed of [1, 2, 3, 4, 5]) {
+          const p = generatePuzzle(cfg({ size, springCount, erosion: EROSION_MAX, terrainSeed: seed }));
+          expect(p.solution).toHaveLength(Math.min(springCount, (size - 2) * (size - 2)));
+          expect(new Set(p.solution.map((c) => `${c.x},${c.y}`)).size).toBe(p.solution.length);
+        }
+      }
+    }
+  });
+
+  it('regression: the exact config that used to crash generation now succeeds', () => {
+    // size 8, springCount 4, erosion 7, seed 2521858826 — bestAny stayed null
+    // pre-fix because only 3 cells cleared the land threshold for 4 springs.
+    const p = generatePuzzle(cfg({ size: 8, springCount: 4, erosion: 7, terrainSeed: 2521858826 }));
+    expect(p.solution).toHaveLength(4);
+  });
+});
+
 describe('randomConfig', () => {
   it('stays within bounds with at least one ocean side', () => {
     for (let i = 0; i < 200; i++) {
diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts
index 6c800a4..cdd43da 100644
--- a/src/model/puzzle.ts
+++ b/src/model/puzzle.ts
@@ -1,6 +1,6 @@
 import { mulberry32 } from '../rng';
 import type { Rng } from '../rng';
-import { generateDome, heightAt } from './heightmap';
+import { generateDome, heightAt, isOceanEdge } from './heightmap';
 import type { Edges, EdgeType, Heightmap } from './heightmap';
 import { simulateFlows } from './flow';
 import type { Cell } from './flow';
@@ -23,6 +23,12 @@ export interface Config {
    * the outflow signature. See {@link erode}.
    */
   erosion: number;
+  /**
+   * How far from the shore springs are drawn from: low values favour land just
+   * above the waterline, high values favour high ground near the peak. 0-15,
+   * centred at {@link REACH_DEFAULT} (no preference — the original behaviour).
+   */
+  reach: number;
   /** Seed for the terrain noise + spring placement (given the other axes). */
   terrainSeed: number;
 }
@@ -33,6 +39,9 @@ export const SPRINGS_MIN = 1;
 export const SPRINGS_MAX = 6;
 export const EROSION_MIN = 0;
 export const EROSION_MAX = 15; // fits the 4 spare bits of the config code
+export const REACH_MIN = 0;
+export const REACH_MAX = 15;
+export const REACH_DEFAULT = 8;
 
 /**
  * A solvable board (Reading A): terrain + a target edge signature are given, and
@@ -71,9 +80,22 @@ export function generatePuzzle(
 ): Puzzle {
   onProgress?.(0);
   const norm = normalizeConfig(config);
-  const { size, springCount, edges, erosion, terrainSeed } = norm;
+  const { size, springCount, edges, erosion, reach, terrainSeed } = norm;
   const rng = mulberry32(terrainSeed);
-  const hm = generateDome(size, size, rng, edges, erosion);
+  let hm = generateDome(size, size, rng, edges, erosion);
+
+  // Heavy erosion (or just an unlucky seed) can wash away most standing land,
+  // leaving a board that reads as almost entirely water — not a fair puzzle.
+  // Back off the *effective* aging in fixed steps, each redrawn from a seed
+  // derived from terrainSeed (so the fallback is itself deterministic), until
+  // enough land remains. Boards that already clear the bar take zero extra
+  // steps and are unaffected.
+  const interior = (size - 2) * (size - 2);
+  const minLand = Math.min(interior, Math.max(springCount, Math.ceil(interior * 0.2)));
+  for (let step = 1; landCandidates(hm, size).length < minLand && step <= 6; step++) {
+    const backoffRng = mulberry32((terrainSeed + step * 0x9e3779b9) >>> 0);
+    hm = generateDome(size, size, backoffRng, edges, erosion * Math.pow(0.7, step));
+  }
   onProgress?.(0.1);
 
   // Candidates: interior land cells with enough elevation to render visibly as
@@ -81,13 +103,63 @@ export function generatePuzzle(
   // height > 0, but very shallow cells near ocean edges can still look like
   // water in the terrain colours — a spring there would be unclickable.
   // 0.1 sits comfortably in the water→land colour transition (0.02–0.15).
-  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.1) candidates.push({ x, y });
+  const candidates = landCandidates(hm, size);
+
+  // The backoff above targets the common case; guarantee the invariant outright
+  // by back-filling with the highest remaining interior cells if it's still
+  // short. Interior cell count is always >= springCount (normalizeConfig
+  // guarantees it), so this always reaches springCount candidates.
+  if (candidates.length < springCount) {
+    const seen = new Set(candidates.map((c) => c.y * size + c.x));
+    const rest: Cell[] = [];
+    for (let y = 1; y < size - 1; y++) {
+      for (let x = 1; x < size - 1; x++) {
+        if (!seen.has(y * size + x)) rest.push({ x, y });
+      }
     }
+    rest.sort((a, b) => heightAt(hm, b.x, b.y) - heightAt(hm, a.x, a.y));
+    candidates.push(...rest.slice(0, springCount - candidates.length));
   }
 
+  // Springs must never land on a "shore" cell — an interior cell directly
+  // adjacent to an ocean edge — because that's exactly where the renderer
+  // draws the gold target outline (see Renderer.drawTarget): a spring there
+  // would double as its own goal indicator, giving it away. `pickSpringIdx`
+  // below exhausts non-shore candidates before ever touching `shoreIdx`, so
+  // shore cells are used only for the picks that genuinely can't be filled
+  // otherwise (tiny boards with springCount larger than the non-shore
+  // interior can support) — never more than the unavoidable minimum.
+  const nonShoreIdx = candidates.map((_, i) => i).filter((i) => !isShoreCell(candidates[i], edges, size));
+  const shoreIdx = candidates.map((_, i) => i).filter((i) => isShoreCell(candidates[i], edges, size));
+
+  // Reach then biases which non-shore candidates get *proposed* as springs:
+  // low values favour land just above the shoreline (but still off it), high
+  // values favour high ground near the peak. Height is a direct proxy for
+  // distance from the ocean edge in this dome model (see generateDome). This
+  // only narrows the proposal pool, not the uniqueness check below — `sig`
+  // covers every real candidate, so a spring set outside the reach band is
+  // still counted as a competing solution. That way "Reach" never makes the
+  // gold-edge hint ambiguous, it just shapes which legible answer generation
+  // reaches for. A hard band keeps the effect legible; if it would leave too
+  // few candidates to place springCount springs, fall back to the full
+  // non-shore pool.
+  const REACH_BAND = 0.5; // fraction of the sorted pool kept at any reach setting
+  const byHeight = [...nonShoreIdx].sort(
+    (a, b) => heightAt(hm, candidates[a].x, candidates[a].y) - heightAt(hm, candidates[b].x, candidates[b].y),
+  );
+  const bandStart = (reach / REACH_MAX) * (1 - REACH_BAND);
+  const lo = Math.floor(bandStart * byHeight.length);
+  const hi = Math.min(byHeight.length, lo + Math.ceil(REACH_BAND * byHeight.length));
+  const windowed = byHeight.slice(lo, hi);
+  const reachPool = windowed.length >= springCount ? windowed : nonShoreIdx;
+
+  // Only ever hand pickSpringIdx a shore tier if non-shore candidates can't
+  // reach springCount at all. Otherwise every attempt's minGap relaxation
+  // (below) already finds a spaced non-shore set on its own by the time it's
+  // fully relaxed — gating here stops an early, still-strict attempt from
+  // reaching into shore just to patch a spacing gap it didn't need to.
+  const springPools = nonShoreIdx.length >= springCount ? [reachPool] : [nonShoreIdx, shoreIdx];
+
   // A candidate's outflow footprint is fixed by the terrain, so precompute each
   // one once as a bitmask over border-cell indices. Because wet sets (and hence
   // outflow) union across springs, a spring set's target signature is simply the
@@ -128,7 +200,7 @@ export function generatePuzzle(
     onProgress?.(P_SIG_END + (0.99 - P_SIG_END) * Math.min(0.98, elapsed / attemptsEstMs));
     // Relax the spacing requirement if the board is too cramped to satisfy it.
     const minGap = attempt < 96 ? 3 : attempt < 112 ? 2 : 1;
-    const idx = pickSpringIdx(candidates, springCount, rng, minGap);
+    const idx = pickSpringIdx(candidates, springPools, springCount, rng, minGap);
     if (idx.length < springCount) continue;
 
     let target = 0n;
@@ -168,6 +240,28 @@ export function generatePuzzle(
   };
 }
 
+/** Is `c` directly adjacent to an ocean edge — a "shore" cell (see Renderer.drawTarget)? */
+function isShoreCell(c: Cell, edges: Edges, size: number): boolean {
+  const neighbors: ReadonlyArray<readonly [number, number]> = [
+    [c.x - 1, c.y],
+    [c.x + 1, c.y],
+    [c.x, c.y - 1],
+    [c.x, c.y + 1],
+  ];
+  return neighbors.some(([nx, ny]) => isOceanEdge(edges, nx, ny, size, size));
+}
+
+/** Interior cells whose height clears the "looks like land" threshold. */
+function landCandidates(hm: Heightmap, size: number): Cell[] {
+  const out: Cell[] = [];
+  for (let y = 1; y < size - 1; y++) {
+    for (let x = 1; x < size - 1; x++) {
+      if (heightAt(hm, x, y) >= 0.1) out.push({ x, y });
+    }
+  }
+  return out;
+}
+
 /** A spring set under consideration during generation, with its signature width. */
 interface Candidate {
   solution: Cell[];
@@ -193,11 +287,13 @@ export function normalizeConfig(c: Config): Config {
   const interior = (size - 2) * (size - 2);
   const springCount = clampInt(c.springCount, SPRINGS_MIN, Math.min(SPRINGS_MAX, interior));
   const erosion = clampInt(c.erosion ?? 0, EROSION_MIN, EROSION_MAX);
+  const reach = clampInt(c.reach ?? REACH_DEFAULT, REACH_MIN, REACH_MAX);
   return {
     size,
     springCount,
     edges: ensureOcean(c.edges),
     erosion,
+    reach,
     terrainSeed: c.terrainSeed >>> 0,
   };
 }
@@ -209,19 +305,22 @@ export function randomConfig(): Config {
     springCount: 2 + Math.floor(Math.random() * 3), // 2,3,4
     edges: rollEdges(Math.random),
     erosion: Math.floor(Math.random() * 9), // 0..8: pristine to well-weathered
+    reach: Math.floor(Math.random() * (REACH_MAX + 1)),
     terrainSeed: (Math.random() * 2 ** 32) >>> 0,
   });
 }
 
-// The config packs into 48 bits — seed(32) · edges(4) · springs(3) · size(5) ·
-// erosion(4) — 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. Erosion occupies bits 44-47, which older (erosion-free) codes left 0,
-// so those codes still decode to a pristine board.
+// The config packs into 52 bits — seed(32) · edges(4) · springs(3) · size(5) ·
+// erosion(4) · reach(4) — which we emit as a 9-char base64url string so the
+// axes aren't legible at a glance. It's obfuscation, not encryption: fully
+// reversible, no secrets. Erosion occupies bits 44-47 and reach bits 48-51,
+// both added after the original 8-char (48-bit) format; older codes are a
+// char shorter and no longer decode (decodeConfig rejects them), so a stale
+// shared link just rolls a fresh board rather than misbehaving.
 const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
-const CODE_LEN = 8;
+const CODE_LEN = 9;
 
-/** A compact, reversible code for a config, e.g. "BQAr3kf9". */
+/** A compact, reversible code for a config, e.g. "BQAr3kf9x". */
 export function encodeConfig(c: Config): string {
   const n = normalizeConfig(c);
   let v =
@@ -229,7 +328,8 @@ export function encodeConfig(c: Config): string {
     (BigInt(edgesToMask(n.edges)) << 32n) |
     (BigInt(n.springCount) << 36n) |
     (BigInt(n.size) << 39n) |
-    (BigInt(n.erosion) << 44n);
+    (BigInt(n.erosion) << 44n) |
+    (BigInt(n.reach) << 48n);
   let out = '';
   for (let i = 0; i < CODE_LEN; i++) {
     out = B64[Number(v & 63n)] + out;
@@ -253,6 +353,7 @@ export function decodeConfig(code: string): Config | null {
     springCount: Number((v >> 36n) & 0x7n),
     edges: maskToEdges(Number((v >> 32n) & 0xfn)),
     erosion: Number((v >> 44n) & 0xfn),
+    reach: Number((v >> 48n) & 0xfn),
     terrainSeed: Number(v & 0xffffffffn) >>> 0,
   });
 }
@@ -289,16 +390,28 @@ function rollEdges(rng: Rng): Edges {
 }
 
 /**
- * Sample the indices of `k` distinct candidates that are pairwise at least
- * `minGap` apart (Chebyshev distance). Returns fewer than `k` only if the spacing
- * can't be met. Indices (not cells) so callers can look up precomputed per-cell
- * data — notably each candidate's outflow signature.
+ * Sample the indices of `k` distinct candidates — drawn from `pools` (each an
+ * array of indices into `candidates`, e.g. a reach-narrowed subset) — that are
+ * pairwise at least `minGap` apart (Chebyshev distance). Pools are tried in
+ * order: every candidate in `pools[0]` is exhausted (shuffled, so still
+ * random within the tier) before any candidate from `pools[1]` is considered,
+ * so a lower-priority pool (e.g. shore cells that must be avoided if
+ * possible) is only reached into for the picks the higher-priority pool
+ * genuinely can't fill. Returns fewer than `k` only if the spacing can't be
+ * met even across every pool. Returns indices into `candidates` (not `pools`)
+ * so callers can look up precomputed per-cell data — notably each candidate's
+ * outflow signature — which must stay indexed over the *full* candidate set
+ * for the uniqueness check to be meaningful.
  */
-function pickSpringIdx(candidates: Cell[], k: number, rng: Rng, minGap: number): number[] {
-  const order = candidates.map((_, i) => i);
-  for (let i = order.length - 1; i > 0; i--) {
-    const j = Math.floor(rng() * (i + 1));
-    [order[i], order[j]] = [order[j], order[i]];
+function pickSpringIdx(candidates: Cell[], pools: number[][], k: number, rng: Rng, minGap: number): number[] {
+  const order: number[] = [];
+  for (const pool of pools) {
+    const shuffled = [...pool];
+    for (let i = shuffled.length - 1; i > 0; i--) {
+      const j = Math.floor(rng() * (i + 1));
+      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+    }
+    order.push(...shuffled);
   }
 
   const chosen: number[] = [];