fuzzyworld

basin / commit

Add terrain aging (erosion) config axis

03c2cc1296c0581fb557b45519f611849dd6f37a · Pavel Safronov · 2026-07-01 14:32:40 -0700 · parents 8552650402a0

Add terrain aging (erosion) config axis

Adds an "Aging" slider (0–15) that simulates hydraulic erosion before
the board is shown: grooves are carved into the dome, the coastline
frays, and the outflow signature shifts — making a heavily-aged board
read as visibly different terrain. Erosion 0 consumes no RNG so all
previously-shared codes reproduce their exact boards. The config code
stays 8 chars (erosion occupies the 4 previously-spare bits, 44→48
of 48 used).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 index.html                  |   4 ++
 readme.md                   |  24 ++++++---
 src/main.ts                 |  14 +++++
 src/model/heightmap.test.ts |  46 ++++++++++++++++-
 src/model/heightmap.ts      | 123 ++++++++++++++++++++++++++++++++++++++++++++
 src/model/puzzle.test.ts    |  45 ++++++++++++++++
 src/model/puzzle.ts         |  34 +++++++++---
 7 files changed, 274 insertions(+), 16 deletions(-)

diff --git a/index.html b/index.html
index 63d3ba2..2a21fd6 100644
--- a/index.html
+++ b/index.html
@@ -29,6 +29,10 @@
             <label><input type="checkbox" id="cfg-wall-s" /> S</label>
             <label><input type="checkbox" id="cfg-wall-w" /> W</label>
           </fieldset>
+          <label class="ctl" title="Simulated erosion: how weathered the terrain is before you arrive. 0 is a pristine dome; higher values carve grooves and change the edge pattern.">
+            Aging <span id="cfg-erosion-val" class="val"></span>
+            <input type="range" id="cfg-erosion" min="0" max="15" step="1" />
+          </label>
           <label class="ctl">
             Terrain
             <span class="seedrow">
diff --git a/readme.md b/readme.md
index 817a862..dd6eb3f 100644
--- a/readme.md
+++ b/readme.md
@@ -22,6 +22,12 @@ 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.
 
+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.
+
 ## How to play
 
 **Goal:** find the positions of the hidden springs that reproduce that signature.
@@ -38,12 +44,13 @@ found, so a board is always produced.
 ## Configuring & sharing 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.
+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.
 
 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
@@ -71,8 +78,9 @@ The model (typed arrays) is decoupled from the renderer so a tiled/WebGL rendere
 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).
+  droplet-based hydraulic **erosion** (the aging axis; skipped, and rng-free, at
+  0) + 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 (with an unambiguous-solution check), and the
diff --git a/src/main.ts b/src/main.ts
index 5d8d330..40f9aad 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -26,6 +26,8 @@ const game = new Game(canvas, status);
 const sizeInput = $<HTMLInputElement>('#cfg-size');
 const sizeVal = $<HTMLElement>('#cfg-size-val');
 const springsInput = $<HTMLInputElement>('#cfg-springs');
+const erosionInput = $<HTMLInputElement>('#cfg-erosion');
+const erosionVal = $<HTMLElement>('#cfg-erosion-val');
 const wall = {
   n: $<HTMLInputElement>('#cfg-wall-n'),
   e: $<HTMLInputElement>('#cfg-wall-e'),
@@ -44,6 +46,8 @@ function syncControls(): void {
   sizeInput.value = String(config.size);
   sizeVal.textContent = `${config.size}×${config.size}`;
   springsInput.value = String(config.springCount);
+  erosionInput.value = String(config.erosion);
+  erosionVal.textContent = agingLabel(config.erosion);
   wall.n.checked = config.edges.n === 'wall';
   wall.e.checked = config.edges.e === 'wall';
   wall.s.checked = config.edges.s === 'wall';
@@ -65,12 +69,22 @@ function apply(next: Config): void {
 
 const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean');
 
+/** Human tag for an erosion level, so the slider reads as more than a number. */
+function agingLabel(n: number): string {
+  const word = n === 0 ? 'pristine' : n <= 4 ? 'weathered' : n <= 9 ? 'eroded' : 'dissected';
+  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) }));
 springsInput.addEventListener('change', () =>
   apply({ ...config, springCount: Number(springsInput.value) }),
 );
+// Live-update the aging label while dragging; erosion is costly, so only rebuild
+// on release.
+erosionInput.addEventListener('input', () => (erosionVal.textContent = agingLabel(Number(erosionInput.value))));
+erosionInput.addEventListener('change', () => apply({ ...config, erosion: Number(erosionInput.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/heightmap.test.ts b/src/model/heightmap.test.ts
index 2b1cf65..8d7956a 100644
--- a/src/model/heightmap.test.ts
+++ b/src/model/heightmap.test.ts
@@ -1,5 +1,5 @@
 import { describe, it, expect } from 'vitest';
-import { generateDome, resolveDrainage, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap';
+import { generateDome, resolveDrainage, erode, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap';
 import type { Edges, Heightmap } from './heightmap';
 import { simulateFlow } from './flow';
 import { mulberry32 } from '../rng';
@@ -84,6 +84,50 @@ describe('generateDome with walls', () => {
   });
 });
 
+describe('erode', () => {
+  it('strength 0 is a no-op that touches neither the terrain nor the rng', () => {
+    const hm = generateDome(10, 10, mulberry32(3));
+    const snapshot = Float32Array.from(hm.data);
+
+    const rng = mulberry32(99);
+    erode(hm, rng, 0);
+    expect(Array.from(hm.data)).toEqual(Array.from(snapshot)); // terrain untouched
+    // The rng stream is untouched, so the very next draw is a fresh first draw.
+    expect(rng()).toBe(mulberry32(99)());
+  });
+
+  it('reshapes the terrain and cuts channels below the pristine surface', () => {
+    const pristine = generateDome(16, 16, mulberry32(7));
+    const aged = generateDome(16, 16, mulberry32(7), ALL_OCEAN, 12);
+
+    // Something changed, and at least some interior cell was cut lower than it
+    // stood on the pristine dome (a groove), not merely silted higher everywhere.
+    let changed = 0;
+    let cutDeeper = 0;
+    for (let y = 1; y < 15; y++) {
+      for (let x = 1; x < 15; x++) {
+        const i = y * 16 + x;
+        if (aged.data[i] !== pristine.data[i]) changed++;
+        if (aged.data[i] < pristine.data[i] - 1e-3) cutDeeper++;
+      }
+    }
+    expect(changed).toBeGreaterThan(0);
+    expect(cutDeeper).toBeGreaterThan(0);
+  });
+
+  it('never lowers an ocean edge below 0', () => {
+    const hm = generateDome(12, 12, mulberry32(21), ALL_OCEAN, 15);
+    for (let x = 0; x < 12; x++) {
+      expect(heightAt(hm, x, 0)).toBe(0);
+      expect(heightAt(hm, x, 11)).toBe(0);
+    }
+    for (let y = 0; y < 12; y++) {
+      expect(heightAt(hm, 0, y)).toBe(0);
+      expect(heightAt(hm, 11, y)).toBe(0);
+    }
+  });
+});
+
 describe('resolveDrainage', () => {
   it('raises a sealed interior pit until it drains', () => {
     // A single deep pit walled off by higher ground; before filling it cannot
diff --git a/src/model/heightmap.ts b/src/model/heightmap.ts
index 1130134..5ddb2db 100644
--- a/src/model/heightmap.ts
+++ b/src/model/heightmap.ts
@@ -62,12 +62,18 @@ function distToOcean(x: number, y: number, width: number, height: number, edges:
  * 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).
+ *
+ * `erosion` (0 = pristine) then ages the freshly-raised dome with simulated
+ * hydraulic erosion before drainage is resolved, carving grooves that also shift
+ * the eventual outflow signature (see {@link erode}). `erosion === 0` consumes no
+ * `rng`, so a pristine board is bit-for-bit what it always was.
  */
 export function generateDome(
   width: number,
   height: number,
   rng: Rng,
   edges: Edges = ALL_OCEAN,
+  erosion = 0,
 ): Heightmap {
   const data = new Float32Array(width * height);
 
@@ -111,10 +117,127 @@ export function generateDome(
   if (peak > 0) for (let i = 0; i < data.length; i++) data[i] /= peak;
 
   const hm: Heightmap = { width, height, data, edges };
+  erode(hm, rng, erosion); // age the terrain before resolving drainage
   resolveDrainage(hm);
   return hm;
 }
 
+// 8-connected neighbours for erosion droplets — diagonals included so carved
+// channels aren't locked to the cardinal grid (the flow model still uses the 4
+// cardinals; erosion is purely terrain shaping).
+const EROSION_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [
+  [0, -1],
+  [1, -1],
+  [1, 0],
+  [1, 1],
+  [0, 1],
+  [-1, 1],
+  [-1, 0],
+  [-1, -1],
+];
+
+/**
+ * Simulated aging by hydraulic ("droplet") erosion — the geomorphic process that
+ * turns a smooth dome into a grooved, dissected landscape. `strength` is roughly
+ * "how much time has passed": each unit rains more droplets over the board.
+ *
+ * Every droplet starts on a random interior cell and runs downhill by steepest
+ * descent, carrying sediment. On a steep, fast reach it picks terrain up (cutting
+ * a channel); where it slows or overfills it drops sediment back (silting flats
+ * and pit floors). Repeated over many droplets this concentrates flow into
+ * dendritic grooves and reshapes where water finally reaches the sea — so a
+ * heavily-aged board reads a visibly different edge signature from its pristine
+ * self.
+ *
+ * Invariants preserved for the caller:
+ * - Ocean-edge cells are never written (they stay exactly 0 — that's the sea a
+ *   droplet washes out into and stops).
+ * - Erosion is capped at the local descent, so it never digs a cell below the
+ *   neighbour it flows to; any residual flats/pits are still cleaned up by the
+ *   {@link resolveDrainage} pass the generator runs afterwards.
+ * - `strength <= 0` returns immediately without touching `rng`, so a pristine
+ *   board is identical to one generated before erosion existed.
+ */
+export function erode(hm: Heightmap, rng: Rng, strength: number): void {
+  if (strength <= 0) return;
+  const { width, height, data, edges } = hm;
+  if (width < 3 || height < 3) return; // no interior to seed droplets on
+
+  // Tuning constants for grid-droplet hydraulic erosion. Chosen so strength 1
+  // lightly weathers the dome and the max clearly dissects it into grooves.
+  const dropsPerCell = 0.6;
+  const maxSteps = width + height;
+  const capacity = 6; // sediment a droplet holds per unit of slope·speed
+  const minSlope = 0.005; // keeps near-flat reaches still mildly erosive
+  const erodeRate = 0.3;
+  const depositRate = 0.25;
+  const gravity = 8;
+  const evaporate = 0.01;
+
+  const drops = Math.round(strength * width * height * dropsPerCell);
+
+  for (let d = 0; d < drops; d++) {
+    // Two rng draws per droplet — the only rng this function consumes, so the
+    // stream stays a deterministic function of (seed, strength, board size).
+    let x = 1 + Math.floor(rng() * (width - 2));
+    let y = 1 + Math.floor(rng() * (height - 2));
+    let sediment = 0;
+    let speed = 0;
+    let water = 1;
+
+    for (let step = 0; step < maxSteps; step++) {
+      // Reaching the sea ends the droplet; its sediment washes out and is lost.
+      if (isOceanEdge(edges, x, y, width, height)) break;
+
+      const i = y * width + x;
+      const h = data[i];
+
+      // Steepest-descent 8-neighbour.
+      let bx = -1;
+      let by = -1;
+      let bh = h;
+      for (const [dx, dy] of EROSION_NEIGHBORS) {
+        const nx = x + dx;
+        const ny = y + dy;
+        if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
+        const nh = data[ny * width + nx];
+        if (nh < bh) {
+          bh = nh;
+          bx = nx;
+          by = ny;
+        }
+      }
+
+      if (bx < 0) {
+        // Local pit: nowhere lower to go. Silt it up with the carried load.
+        data[i] = Math.min(1, h + sediment);
+        break;
+      }
+
+      const drop = h - bh; // > 0
+      const cap = Math.max(drop, minSlope) * (speed + 1) * water * capacity;
+
+      if (sediment > cap) {
+        // Over capacity → deposit, but never above the cell we came to rest on.
+        const dep = Math.min((sediment - cap) * depositRate, sediment);
+        data[i] = Math.min(1, h + dep);
+        sediment -= dep;
+      } else {
+        // Under capacity → cut, but never below the neighbour we descend to, so
+        // the path stays monotone and we don't spike a fresh pit.
+        const ero = Math.min((cap - sediment) * erodeRate, drop);
+        data[i] = h - ero;
+        sediment += ero;
+      }
+
+      speed = Math.sqrt(Math.max(0, speed * speed + drop * gravity));
+      water *= 1 - evaporate;
+      x = bx;
+      y = by;
+    }
+  }
+}
+
 const DRAIN_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [
   [0, -1],
   [1, 0],
diff --git a/src/model/puzzle.test.ts b/src/model/puzzle.test.ts
index fbffa72..bd7383b 100644
--- a/src/model/puzzle.test.ts
+++ b/src/model/puzzle.test.ts
@@ -10,6 +10,7 @@ import {
   SIZE_MAX,
   SPRINGS_MIN,
   SPRINGS_MAX,
+  EROSION_MAX,
 } from './puzzle';
 import type { Config } from './puzzle';
 import { simulateFlows } from './flow';
@@ -24,6 +25,7 @@ const cfg = (over: Partial<Config> = {}): Config => ({
   size: SIZE,
   springCount: COUNT,
   edges: ALL_OCEAN,
+  erosion: 0,
   terrainSeed: 4242,
   ...over,
 });
@@ -136,6 +138,8 @@ describe('config code', () => {
       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 }),
+      cfg({ erosion: 1 }),
+      cfg({ erosion: EROSION_MAX, size: 16, springCount: 4 }),
     ];
     for (const c of configs) {
       const code = encodeConfig(c);
@@ -164,6 +168,47 @@ describe('config code', () => {
     expect(tiny.size).toBe(SIZE_MIN);
     expect(tiny.springCount).toBe(SPRINGS_MIN);
   });
+
+  it('clamps erosion into range and defaults a missing value to 0', () => {
+    expect(normalizeConfig(cfg({ erosion: 999 })).erosion).toBe(EROSION_MAX);
+    expect(normalizeConfig(cfg({ erosion: -5 })).erosion).toBe(0);
+    // Older codes predate erosion, so decoding must land on a pristine board.
+    const legacy = cfg();
+    delete (legacy as Partial<Config>).erosion;
+    expect(normalizeConfig(legacy).erosion).toBe(0);
+  });
+});
+
+describe('erosion (aging)', () => {
+  it('leaves a pristine (erosion 0) board bit-for-bit unchanged', () => {
+    // Backward compatibility: adding the erosion axis must not consume rng when
+    // it is 0, so every previously-shared code reproduces its exact board.
+    const pristine = generatePuzzle(cfg({ erosion: 0, terrainSeed: 777 }));
+    const aged = generatePuzzle(cfg({ erosion: 5, terrainSeed: 777 }));
+    expect(Array.from(aged.hm.data)).not.toEqual(Array.from(pristine.hm.data));
+  });
+
+  it('keeps the drainage invariant and ocean edges at 0 after aging', () => {
+    for (const erosion of [1, 5, 10, EROSION_MAX]) {
+      for (const seed of [1, 42, 12345]) {
+        const p = generatePuzzle(cfg({ size: 14, erosion, edges: CANYON, terrainSeed: seed }));
+        // Ocean edges stay exactly 0 (the sea erosion washes out into).
+        for (let y = 0; y < 14; y++) {
+          for (let x = 0; x < 14; x++) {
+            if (isOceanEdge(p.edges, x, y, 14, 14)) expect(p.hm.data[y * 14 + x]).toBe(0);
+          }
+        }
+        // Every interior cell still drains out to an ocean edge.
+        for (let y = 1; y < 13; y++) {
+          for (let x = 1; x < 13; x++) {
+            const { outflow } = simulateFlows(p.hm, [{ x, y }]);
+            expect(outflow.length).toBeGreaterThan(0);
+            for (const o of outflow) expect(isOceanEdge(p.edges, o.x, o.y, 14, 14)).toBe(true);
+          }
+        }
+      }
+    }
+  });
 });
 
 describe('randomConfig', () => {
diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts
index c928e4e..50f42f3 100644
--- a/src/model/puzzle.ts
+++ b/src/model/puzzle.ts
@@ -17,6 +17,12 @@ export interface Config {
   springCount: number;
   /** Per-side edge types (ocean vs wall). */
   edges: Edges;
+  /**
+   * How much simulated erosion (aging) the terrain underwent before the board
+   * was shown. 0 = a pristine dome; higher values carve deeper grooves and shift
+   * the outflow signature. See {@link erode}.
+   */
+  erosion: number;
   /** Seed for the terrain noise + spring placement (given the other axes). */
   terrainSeed: number;
 }
@@ -25,6 +31,8 @@ export const SIZE_MIN = 5;
 export const SIZE_MAX = 20;
 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
 
 /**
  * A solvable board (Reading A): terrain + a target edge signature are given, and
@@ -59,9 +67,9 @@ export interface Puzzle {
  */
 export function generatePuzzle(config: Config): Puzzle {
   const norm = normalizeConfig(config);
-  const { size, springCount, edges, terrainSeed } = norm;
+  const { size, springCount, edges, erosion, terrainSeed } = norm;
   const rng = mulberry32(terrainSeed);
-  const hm = generateDome(size, size, rng, edges);
+  const hm = generateDome(size, size, rng, edges, erosion);
 
   // Candidates: interior (non-edge) land cells. After drainage resolution every
   // interior cell has height > 0 and a strictly-decreasing path to an ocean edge.
@@ -147,7 +155,14 @@ 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 };
+  const erosion = clampInt(c.erosion ?? 0, EROSION_MIN, EROSION_MAX);
+  return {
+    size,
+    springCount,
+    edges: ensureOcean(c.edges),
+    erosion,
+    terrainSeed: c.terrainSeed >>> 0,
+  };
 }
 
 /** A wholly random board within sensible bounds. */
@@ -156,13 +171,16 @@ export function randomConfig(): Config {
     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),
+    erosion: Math.floor(Math.random() * 9), // 0..8: pristine to well-weathered
     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.
+// 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.
 const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
 const CODE_LEN = 8;
 
@@ -173,7 +191,8 @@ export function encodeConfig(c: Config): string {
     BigInt(n.terrainSeed >>> 0) |
     (BigInt(edgesToMask(n.edges)) << 32n) |
     (BigInt(n.springCount) << 36n) |
-    (BigInt(n.size) << 39n);
+    (BigInt(n.size) << 39n) |
+    (BigInt(n.erosion) << 44n);
   let out = '';
   for (let i = 0; i < CODE_LEN; i++) {
     out = B64[Number(v & 63n)] + out;
@@ -196,6 +215,7 @@ export function decodeConfig(code: string): Config | null {
     size: Number((v >> 39n) & 0x1fn),
     springCount: Number((v >> 36n) & 0x7n),
     edges: maskToEdges(Number((v >> 32n) & 0xfn)),
+    erosion: Number((v >> 44n) & 0xfn),
     terrainSeed: Number(v & 0xffffffffn) >>> 0,
   });
 }