fuzzyworld

tower / commit

feat(water): lakes, seas and rivers as surfaces, with waves, flow and falls

80bcc97a8c882b389a99148f7864e8bc208a521f · Pavel Safronov · 2026-09-16 21:15:57 -0700 · parents e7a9f9ddc578

Claude Code usage: this commit ~316.6K out / ~174.8M cached · cost unknown (no per-model trailer) · session 1 tally 1.5K in / 1.3M out / 443.9M cached · usage by commit and session

feat(water): lakes, seas and rivers as surfaces, with waves, flow and falls

Phase 3 of world-generation.md (D-066).

- every lake, sea and river is a surface at its own level, meshed per
  tile, with the ground under it drawn as bed
- waves come from each body's wind and fetch: fetch-limited JONSWAP
  seas capped at fully developed, which a ring sea always is; lakes
  and seas get a wind, and the editor shows the sea it raises
- rivers run at Manning's speed with ripples carried downstream, and
  whiten on their rapids
- waterfalls come from the river profile's steps, merged into single
  drops with a gorge below; the sheets drift antispinward by the
  free-fall drift canon/motion.ts computes
- rivers start and end at the water's edge, not across a lake floor
- the beam draws after water, so distant seas no longer blur it
- the compiled world is cached in IndexedDB between visits; the
  screenshot tour compiles once and caps each page at two workers
- a new tour shot of the Long Water's 680 m fall

Usage: 1.5K in / 1.3M out / 443.9M cached (session tally)
rtk:   3.3M saved, 37.7% over 3553 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md                                      |  23 +-
 decisions.md                                   |  41 ++++
 design.md                                      |  32 ++-
 docs/screenshots/desktop-exterior-true.png     | Bin 490892 -> 539616 bytes
 docs/screenshots/desktop-exterior.png          | Bin 507282 -> 507001 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 462721 -> 463201 bytes
 docs/screenshots/desktop-interior-falls.png    | Bin 0 -> 634992 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 305427 -> 298259 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 510659 -> 508161 bytes
 docs/screenshots/desktop-interior-range.png    | Bin 732982 -> 732424 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 384302 -> 383649 bytes
 docs/screenshots/desktop-interior.png          | Bin 408695 -> 771885 bytes
 docs/screenshots/desktop-map-sightline.png     | Bin 915645 -> 915694 bytes
 docs/screenshots/desktop-map.png               | Bin 908594 -> 908759 bytes
 docs/screenshots/desktop-reader.png            | Bin 891868 -> 892028 bytes
 docs/screenshots/mobile-exterior.png           | Bin 114711 -> 114766 bytes
 docs/screenshots/mobile-interior.png           | Bin 116766 -> 224461 bytes
 docs/screenshots/mobile-map.png                | Bin 239561 -> 239624 bytes
 docs/screenshots/mobile-panel.png              | Bin 80809 -> 95792 bytes
 docs/screenshots/overview.png                  | Bin 1349204 -> 1539806 bytes
 index.html                                     |   8 +
 src/terrain/cache.ts                           |  71 ++++++
 src/terrain/compile.ts                         | 140 ++++++++++-
 src/terrain/falls.ts                           |  81 +++++++
 src/terrain/field.ts                           | 115 +++++++++-
 src/terrain/map-image.ts                       |   7 +-
 src/terrain/service.ts                         |  35 ++-
 src/terrain/shapes.ts                          |   8 +
 src/terrain/tile.ts                            | 129 ++++++++++-
 src/terrain/water.ts                           |  91 ++++++++
 src/terrain/worker.ts                          |   4 +-
 src/ui/app.ts                                  |  42 ++++
 src/views/interior-model.ts                    | 306 ++++++++++++++++++++++++-
 src/views/interior.ts                          |   8 +-
 src/views/terrain-lod.ts                       |  26 ++-
 src/world/schema.ts                            |  30 ++-
 test/terrain.test.ts                           |  50 +++-
 test/water.test.ts                             |  81 +++++++
 tools/screenshots.mjs                          |  58 ++++-
 world-generation.md                            |  48 +++-
 40 files changed, 1378 insertions(+), 56 deletions(-)

diff --git a/README.md b/README.md
index 72d35d2..4de7e10 100644
--- a/README.md
+++ b/README.md
@@ -65,9 +65,12 @@ and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI
 the point: a shot taken this way fails when the UI fails, so a black canvas ends up
 in the picture instead of hiding behind a passing unit test.
 
-The whole tour takes about **a minute**, and waits on the world being generated
-rather than on fixed delays: a shot is taken once the terrain has compiled, the map
-is drawn, and every tile of ground the view wants has arrived. It once took six and a
+The whole tour takes about **a minute and a half**, and waits on the world being
+generated rather than on fixed delays: a shot is taken once the terrain has compiled, the
+map is drawn, and every tile of ground the view wants has arrived. It is kept light on the
+machine: the world is **compiled once** for the whole tour and every shot starts from that
+cache, and each page runs **two** terrain workers rather than a full pool — four pages at
+once each compiling the world with six workers was a laptop running hot. It once took six and a
 half minutes, nearly all of them spent rendering Tower's haze integral on the CPU —
 the default headless browser has no GPU. It now asks for the full Chrome build, which reaches
 the real one, and photographs four shots at a time, since a shot is mostly spent
@@ -82,6 +85,7 @@ another machine will show a large one-off divergence rather than a regression.
 | [exterior-true](docs/screenshots/desktop-exterior-true.png) | true hull — bare skin, and the land visible through the far cap window |
 | [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides |
 | [interior-range](docs/screenshots/desktop-interior-range.png) | from Snowcrown's summit: the Girdle's ridges and valleys running round the world |
+| [interior-falls](docs/screenshots/desktop-interior-falls.png) | the Long Water falling 680 m off the range into its gorge |
 | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away |
 | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away |
 | [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, features, a detail card and the probe, no editing |
@@ -191,6 +195,9 @@ note stripped — the **Export static map content** button writes it.
 Generation is deterministic. The seeds are in the file, so every reader sees the same
 world the author does, to the tree.
 
+The compiled world is kept in the browser's IndexedDB, keyed by the world and the build
+that compiled it, so a returning visit to an unchanged world skips compiling.
+
 When the world loads, and after any edit that changes the ground, it is compiled in a
 worker — rivers given their long profiles, lake levels found, summits measured. If that
 takes longer than 400 ms a dialog says what Tower is making, one step at a time. A
@@ -253,6 +260,11 @@ caption — still **walks you there**, which is the only way to cross the world
 one move: most of it is beyond the haze horizon from any given spot, so travelling
 is more use than turning.
 
+**The water is water.** Lakes, seas and rivers are surfaces at their own levels, with
+waves sized from each body's wind and fetch, rivers running downstream, and rapids white.
+A river that cannot slope falls: the Long Water drops 680 m off the range into a gorge,
+and the falling water drifts antispinward as it goes — the spin, shown by what falls.
+
 **Or fly.** W A S D or the arrow keys move you, E climbs and Q descends, and shift
 goes five times faster. Speed follows your height above the ground: a brisk walk
 at standing height, and a sea crossed in seconds from ten kilometres up, with nothing
@@ -317,6 +329,11 @@ its unnamed summits stand and how many there are, how rugged it is, how deeply w
 cuts it, and its snowline. A change regenerates the range — a few seconds for the
 Girdle, under the status dialog — and edits made meanwhile are folded into the next.
 
+Select a **lake or sea** to set its wind: the direction it blows toward and its speed.
+The editor says what sea that raises — a 10 m/s wind over The Deep makes waves under a
+metre; the same wind round a ring sea, which never runs out of water to blow over, makes
+2 m, and 20 m/s makes 8.6. Until Tower's weather plan arrives, that wind is steady.
+
 A **Region** is a label, and it can also be ground Tower raised: give it an uplift and
 everything inside it is lifted to that height above the open ground, wherever that is
 higher than what is there. The Uplands are one.
diff --git a/decisions.md b/decisions.md
index 5e32171..bebb3da 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2132,3 +2132,44 @@ latest edit made during it, and runs that next.
 
 **Cost:** a whole-world compile of about 4 s in a browser worker, most of it the Girdle.
 Loading to a drawn map is about 4 s; the status dialog shows for it.
+
+---
+
+## D-066 — Water is geometry, and waterfalls lean
+
+**Built.** `world-generation.md` phase 3; §17 there has the detail.
+
+**Every lake, sea and river is a surface at its own level**, meshed per tile, with the
+ground beneath it drawn as bed and the depth test finding the shore. Waves are the wind's:
+fetch-limited JONSWAP seas, capped at fully developed, which is what a ring sea always is.
+Rivers run at Manning's speed and whiten on their rapids.
+
+**Waterfalls were not added; they were found.** The river profile of D-064 pools reaches
+and steps between them, and a step is a fall. Merged where the steps come in a run, they
+give the Long Water a 680 m fall off the range with a gorge below, a 170 m fall into The
+Deep, and Eastwall Water a 440 m fall. The sheets drift antispinward as they drop, by the
+free-fall drift canon already computed: §1.9.2 said the waterfall would be the compass,
+and it now is one.
+
+**Three faults the falls surfaced**, each a straight cliff where there should be none: the
+step applied across a whole valley's width (fixed by shaping walls from a smoothly ramped
+wall level), across a whole embankment's (the embankment uses the ramp too), and a second
+cliff kilometres out where a steep gorge wall's straight term fell below the valley's
+curved one (the gorge shares the curvature). Tile skirts now reach past each tile's
+steepest step, or a gorge wall's cracks showed as dark panels.
+
+**The beam went fuzzy over the seas** — the author saw it. Water blends and is drawn after
+the ground; the beam was drawn before the water, so distant seas blended over it. The beam
+is always in front of water, and is now drawn last.
+
+**A river met its lake badly** — the author saw that too. v1's Long Water starts at the
+centre of Hollow Tarn, so its channel was cut across the lake floor and drawn as a stripe
+across the tarn. Rivers are now trimmed to the water's edge, found by sampling the water
+rather than the lake's outline, which a tarn's water does not reach.
+
+**The screenshot tour was running the author's laptop hot**, and deserved it: fifteen
+shots, four at a time, each in a fresh browser context compiling the whole world with a
+full pool of six workers. The compiled world is now cached in IndexedDB — which returning
+readers get too, as §5.4 planned — keyed so a new build never reads an old world. The tour
+compiles once, hands every shot the warm cache, caps each page at two workers, and reports
+any shot that compiled anyway. It takes about 100 s and averages about 3.4 cores.
diff --git a/design.md b/design.md
index 7638663..677df5b 100644
--- a/design.md
+++ b/design.md
@@ -574,6 +574,9 @@ gathering each other, and leave with the fourth through the single gap.
   weigh fractionally more or less for it (§1.9.2). Everything else runs axially,
   where Coriolis is exactly zero.
 - Whoever holds The Narrows holds the axis of the world. The Capital sits behind it.
+- **The Long Water falls twice** (D-066): 680 m off the range below Hollow Tarn, into a
+  gorge, and 170 m into The Deep at its mouth. Eastwall Water falls 440 m off its side of
+  the range. Each leans antispinward as it drops.
 
 **The drainage needed the floor raised, and generating it honestly is what showed it**
 (D-063, D-064). As v1 authored it, the ground between the Girdle's foot and the Dry
@@ -666,6 +669,9 @@ src/
                     main thread and under vitest alike (world-generation.md §5)
     compile.ts      the world compile: shapes, river profiles, lake levels
     mountains.ts    summits, ridge graphs, steady-state erosion, pinned summits
+    water.ts        wind seas, fetch, river speed and foam: the water's physics
+    falls.ts        waterfall sheets, leaning antispinward as they fall
+    cache.ts        the compiled world kept in IndexedDB between visits
     field.ts        the elevation function: one answer to "how high is it here"
     noise.ts        seeded simplex noise on the cylinder, and the hash under it
     shapes.ts       signed distance, smoothed lines, on the unrolled (z, θR) plane
@@ -878,10 +884,25 @@ The far cap's glazing is set a kilometre into its own frame, so from inside ther
 a **1 km reveal** around it. That is the only place the wall depth of D-044 can be
 seen from within the world, which is why it is the only place it is drawn.
 
-Water has a surface, but not yet a mesh of its own. A tile shows whichever is higher,
-the ground or the water on it, so a lake reads as flat water at its level and standing
-in one puts you on its surface. Waves, flow and shorelines are world-generation.md
-phase 3.
+**Water is geometry** (D-066). Every lake, sea and river is a surface at its own level
+— a level in Tower is a constant radius, so a lake curves up with the floor — drawn over
+the ground with the depth test finding the shoreline, and the ground under it drawn as
+bed. Deep water is dark and shallow water shows the bed through it. Waves are sized from
+each body's wind and the fetch it blows over, by the relations fitted to real seas, and
+drawn as four components that displace the surface near the eye and shade it beyond;
+rivers carry ripples downstream at the speed Manning's equation gives them, and turn
+white on their rapids. Water mirrors the hazed land overhead, and glints where it
+mirrors the beam.
+
+**Waterfalls lean.** A river's profile steps where it cannot slope — off a range, into
+The Deep — and a step is a fall with a gorge below it. The falling sheet leaves the lip at
+the river's speed and drifts antispinward by the free-fall drift of §1.9.2, so the
+Long Water's 680 m fall off the range lands about 75 m antispinward of its lip. It is the
+first thing in the view that shows the spin by leaning, which §1.9.2 always said the
+waterfall would be.
+
+The beam is drawn after all water: it is always in front of it, and drawn first it was
+blended under the far side's seas.
 
 ---
 
@@ -1022,7 +1043,8 @@ the same reason.
 **Live world generation** — in progress, planned in world-generation.md (D-062).
 Phase 1 ✅ (D-063): world file v2, the elevation function, the worker pool, tiled
 ground, the status dialog, keyboard flight, the map drawn from the features. Phase 2 ✅
-(D-065): mountain ranges of summits, ridges and eroded valleys, and summit editing. Phases 2–8
+(D-065): mountain ranges of summits, ridges and eroded valleys, and summit editing. Phase 3
+✅ (D-066): water as surfaces with waves and flow, and waterfalls that lean. Phases 2–8
 bring procedural scenery, settlements, and weather as Tower's published schedule of
 wind and waves — which this section used to list as out of scope. Clouds and rain stay
 undrawn (D-049).
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 3947fd1..768ac18 100644
Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ
diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png
index 6968479..b74863c 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png
index 5a15f26..9769d67 100644
Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ
diff --git a/docs/screenshots/desktop-interior-falls.png b/docs/screenshots/desktop-interior-falls.png
new file mode 100644
index 0000000..b3e4614
Binary files /dev/null and b/docs/screenshots/desktop-interior-falls.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index 6a5b868..2697ac5 100644
Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ
diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png
index fdff69e..3a722d9 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-range.png b/docs/screenshots/desktop-interior-range.png
index 44c5839..fc5fdf0 100644
Binary files a/docs/screenshots/desktop-interior-range.png and b/docs/screenshots/desktop-interior-range.png differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index f5f4a6c..3dc377e 100644
Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ
diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png
index b12c751..9c66fac 100644
Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ
diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png
index 2669f43..3b818e1 100644
Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ
diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png
index eb4c820..af547ff 100644
Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ
diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png
index 31a0ec0..da7b098 100644
Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index 640e82e..efa353a 100644
Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ
diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png
index b849ea4..3598266 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png
index db21807..1c95243 100644
Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 56797ef..7c944aa 100644
Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 1088278..4506473 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index abac372..1c200d7 100644
--- a/index.html
+++ b/index.html
@@ -128,6 +128,14 @@
         <label>Snowline, m <input id="f-snow" type="number" step="50" /></label>
         <p class="hint">Changing any of these regenerates the range: a few seconds for the Girdle.</p>
       </div>
+      <div id="f-water" hidden>
+        <p class="sub">Wind <span class="dim">— until Tower's weather plan, the steady wind over this water</span></p>
+        <label>Blowing toward, degrees <span class="dim">— 0 deepward, 90 spinward</span>
+          <input id="f-wind-dir" type="number" step="15" min="0" max="359" /></label>
+        <label>Speed <span id="f-wind-speed-value" class="dim"></span>
+          <input id="f-wind-speed" type="range" min="0" max="30" step="1" /></label>
+        <p class="hint" id="f-sea-state"></p>
+      </div>
       <label>ID <span class="dim">— permalink target; follows the name until you edit it</span>
         <input id="f-id" type="text" /></label>
       <label>Public blurb <textarea id="f-blurb" rows="2" placeholder="Shown to readers."></textarea></label>
diff --git a/src/terrain/cache.ts b/src/terrain/cache.ts
new file mode 100644
index 0000000..f42fa85
--- /dev/null
+++ b/src/terrain/cache.ts
@@ -0,0 +1,71 @@
+/**
+ * The compiled world, kept in IndexedDB between visits. See world-generation.md §5.4.
+ *
+ * Compiling is the slow part of loading — mostly the Girdle's erosion, a few seconds —
+ * and its result depends only on the world file and the code that compiles it. So it is
+ * stored under a key made of both, and a returning visit with the same world and the same
+ * build skips it. Nothing here is authoritative: a miss, a quota error or storage that is
+ * not there at all just means compiling again.
+ */
+import type { CompiledWorld } from './compile.js'
+
+const DB = 'tower-terrain'
+const STORE = 'compiled'
+/** Entries kept. The author's edits make new worlds constantly; old ones are not wanted. */
+const KEEP = 3
+
+function open(): Promise<IDBDatabase> {
+  return new Promise((resolve, reject) => {
+    const req = indexedDB.open(DB, 1)
+    req.onupgradeneeded = () => req.result.createObjectStore(STORE)
+    req.onsuccess = () => resolve(req.result)
+    req.onerror = () => reject(req.error)
+  })
+}
+
+/** A short stable key for a long string: FNV-1a, twice, 64 bits of it in hex. */
+export function cacheKey(text: string): string {
+  let a = 0x811c9dc5, b = 0x9dc5811c
+  for (let i = 0; i < text.length; i++) {
+    const c = text.charCodeAt(i)
+    a = Math.imul(a ^ c, 16777619)
+    b = Math.imul(b ^ c, 16777619) ^ (a >>> 13)
+  }
+  return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`
+}
+
+export async function loadCompiled(key: string): Promise<CompiledWorld | null> {
+  try {
+    const db = await open()
+    return await new Promise((resolve) => {
+      const get = db.transaction(STORE).objectStore(STORE).get(key)
+      get.onsuccess = () => resolve((get.result as { world: CompiledWorld } | undefined)?.world ?? null)
+      get.onerror = () => resolve(null)
+    })
+  } catch {
+    return null
+  }
+}
+
+export async function saveCompiled(key: string, world: CompiledWorld): Promise<void> {
+  try {
+    const db = await open()
+    const store = db.transaction(STORE, 'readwrite').objectStore(STORE)
+    store.put({ world, at: Date.now() }, key)
+    // Drop all but the newest few.
+    const all = store.openCursor()
+    const entries: { key: IDBValidKey; at: number }[] = []
+    all.onsuccess = () => {
+      const cursor = all.result
+      if (cursor) {
+        entries.push({ key: cursor.key, at: (cursor.value as { at: number }).at })
+        cursor.continue()
+        return
+      }
+      entries.sort((x, y) => y.at - x.at)
+      for (const e of entries.slice(KEEP)) store.delete(e.key)
+    }
+  } catch {
+    // Not caching is only slower.
+  }
+}
diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts
index 6a959e5..6b35f0f 100644
--- a/src/terrain/compile.ts
+++ b/src/terrain/compile.ts
@@ -15,9 +15,10 @@
 import { RADIUS_M } from '../canon/constants.js'
 import type { Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js'
 import { type CMountainGroup, compileMountains } from './mountains.js'
+import { FALL_MIN_M, fetchOf, foamOf, manningSpeed, windSea } from './water.js'
 import { hash3 } from './noise.js'
 import {
-  type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, smoothLine,
+  type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, sliceLine, smoothLine,
 } from './shapes.js'
 import { elevationStage, prepare, Stage } from './field.js'
 
@@ -45,21 +46,55 @@ export interface CBasin {
   shoreM: number
   rimM: number
   maxInsideM: number
+  /** Its sea state: amplitude of the main wave, its length, and the direction it runs. */
+  waveAmpM: number
+  waveLengthM: number
+  waveDirRad: number
   seed: number
   box: Box
 }
 
+/** A waterfall: where a river's surface steps down, and by how much. */
+export interface CFall {
+  river: string
+  /** The lip, on the river's centre line. */
+  z: number
+  a: number
+  /** Unit direction the river flows over it, in (z, a). */
+  dz: number
+  da: number
+  topM: number
+  bottomM: number
+  widthM: number
+  /** Speed the water leaves the lip at. */
+  speedMS: number
+}
+
 export interface CRiver {
   id: string
   line: Line
   /** Water surface at each line point, metres. Never rises downstream. */
   level: number[]
+  /**
+   * What the valley's walls are shaped from: the same as `level`, except through a
+   * waterfall and the gorge below it, where it ramps smoothly from the lip's height to
+   * the foot's. The channel steps; the valley around it does not.
+   */
+  wallLevel: number[]
   /** Channel half-width at each point, metres. */
   halfWidth: number[]
   /** Depth of the channel bed below the surface, metres. */
   bed: number[]
   /** Flow at each point, m³/s, tributaries included. */
   flow: number[]
+  /** Mean speed at each point, m/s (Manning), and how white the water is there, 0..1. */
+  speed: number[]
+  foam: number[]
+  /**
+   * Segments whose downstream end drops a waterfall's height. The surface steps at the
+   * segment's middle rather than sloping across it (field.ts).
+   */
+  fallAt: number[]
   /** How far from the centre line the valley, or the embankment carrying it, can reach. */
   reach: number
   box: Box
@@ -74,6 +109,7 @@ export interface CompiledWorld {
   mountains: CMountainGroup[]
   basins: CBasin[]
   rivers: CRiver[]
+  falls: CFall[]
 }
 
 /** One line of the status dialog: a step that is about to run (world-generation.md §10.1). */
@@ -94,7 +130,7 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
   const cw: CompiledWorld = {
     seed: world.seed,
     ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM },
-    uplifts: [], mountains: [], basins: [], rivers: [],
+    uplifts: [], mountains: [], basins: [], rivers: [], falls: [],
   }
   const seedOf = (f: Feature): number => hash3(world.seed, f.seed, 0x5eed)
 
@@ -143,6 +179,17 @@ export function compileWorld(world: World, onStep: StepListener = () => {}): Com
   return cw
 }
 
+/**
+ * A body of water's sea state, from its wind and the fetch the wind has over it. The
+ * amplitude is of the main component: a significant wave height is crest to trough of
+ * the biggest third, and the shader sums several smaller components under the main one.
+ */
+function waves(speedMS: number, directionDeg: number, shape: Shape): { waveAmpM: number; waveLengthM: number; waveDirRad: number } {
+  const dir = (directionDeg * Math.PI) / 180
+  const sea = windSea(speedMS, fetchOf(shape, dir))
+  return { waveAmpM: sea.heightM * 0.35, waveLengthM: sea.lengthM, waveDirRad: dir }
+}
+
 /** The greatest distance from any interior point to the edge, sampled. */
 function maxInside(shape: Shape): number {
   if (shape.type === 'band') return (shape.zTo - shape.zFrom) / 2
@@ -172,6 +219,7 @@ function compileBasin(f: Lake | Sea, seed: number, cw: CompiledWorld): CBasin |
     shoreM: f.shoreM,
     rimM: f.kind === 'lake' ? f.rimM : 0,
     maxInsideM: Math.max(10, maxInside(shape)),
+    ...waves(f.wind.speedMS, f.wind.directionDeg, shape),
     seed,
     box: grow(shapeBox(shape), flooded ? f.shoreM + 8000 : 50),
   }
@@ -223,8 +271,8 @@ export const EMBANKMENT_SLOPE = 0.15
 const OUTLET_REACH_M = 3000
 
 function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener): void {
-  const lines = new Map(rivers.map((r) => [r.id, smoothLine(
-    (r.geometry.type === 'polyline' ? r.geometry.points : []) as [number, number][], RIVER_STEP_M)]))
+  const lines = new Map(rivers.map((r) => [r.id, trimToShores(smoothLine(
+    (r.geometry.type === 'polyline' ? r.geometry.points : []) as [number, number][], RIVER_STEP_M), cw.basins, cw)]))
   const endOf = (l: Line): [number, number] => [l.pts[l.pts.length - 2]!, l.pts[l.pts.length - 1]!]
 
   /** Where a river ends: into which basin or river, and where along that river. */
@@ -307,7 +355,8 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener)
     // A river that rises in a lake leaves it at the lake's level: the tarns are the top
     // of the world's water loop (design.md §1.11).
     const [sz, sa] = [line.pts[0]!, line.pts[1]!]
-    const lake = cw.basins.find((b) => signedDistance(b.shape, sz, sa) > 0)
+    // Within a step of the shore: the line was trimmed to start there (trimToShores).
+    const lake = cw.basins.find((b) => signedDistance(b.shape, sz, sa) > -RIVER_STEP_M)
     let mouth: number
     if (o && 'basin' in o) mouth = o.basin.levelM
     else if (o && 'river' in o && done.has(o.river.id)) mouth = done.get(o.river.id)!.level[o.at]!
@@ -358,12 +407,62 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener)
     const level = fitted.map((v, i) =>
       Math.min(cap, Math.max(mouth + grade * (total - line.s[i]!), v - grade * line.s[i]!)))
 
+    /**
+     * Where the profile steps down several times in a row — a river coming off a range
+     * steps every 200 m for kilometres — the steps become one fall. A river meeting a hard
+     * edge drops at the lip and cuts a gorge below it; it does not descend a staircase of
+     * identical ten-storey falls. The run's lower points take the bottom level, which is
+     * what cuts the gorge (field.ts).
+     */
+    const wallLevel = [...level]
+    for (let i = 0; i < n - 1; i++) {
+      if (level[i]! - level[i + 1]! < FALL_MIN_M) continue
+      let j = i + 1
+      while (j < n - 1 && level[j]! - level[j + 1]! >= FALL_MIN_M) j++
+      for (let k = i + 1; k < j; k++) {
+        wallLevel[k] = level[i]! + ((level[j]! - level[i]!) * (k - i)) / (j - i)
+        level[k] = level[j]!
+      }
+      i = j - 1
+    }
+
     // Far enough for the widest embankment this river has to build.
     let fill = 0
     for (let i = 0; i < n; i++) fill = Math.max(fill, level[i]! - surface[i]!)
     const reach = Math.max(...halfWidth) + Math.max(4500, fill / EMBANKMENT_SLOPE)
+
+    /**
+     * Speed and whiteness from the local grade, and waterfalls where the surface steps.
+     * The profile's pools come out level and meet at steps (the isotonic fit pools
+     * reaches together), so a big enough step between two points is a fall, not a
+     * steep chute: the river holds its level to the lip and drops.
+     */
+    const speed: number[] = [], foam: number[] = [], fallAt: number[] = []
+    for (let i = 0; i < n; i++) {
+      const j0 = Math.max(0, i - 2), j1 = Math.min(n - 1, i + 2)
+      const run = line.s[j1]! - line.s[j0]! || 1
+      let drop = level[j0]! - level[j1]!
+      // A fall inside the window is not the reach's grade: take it out.
+      for (let k = j0; k < j1; k++) if (level[k]! - level[k + 1]! >= FALL_MIN_M) drop -= level[k]! - level[k + 1]!
+      const grade = Math.max(0, drop / run)
+      speed.push(manningSpeed(bed[i]!, grade))
+      foam.push(foamOf(grade))
+    }
+    for (let i = 0; i < n - 1; i++) {
+      const top = level[i]!, bottom = level[i + 1]!
+      if (top - bottom < FALL_MIN_M) continue
+      fallAt.push(i)
+      const z0 = line.pts[2 * i]!, a0 = line.pts[2 * i + 1]!
+      const z1 = line.pts[2 * i + 2]!, a1 = line.pts[2 * i + 3]!
+      const len = Math.hypot(z1 - z0, a1 - a0) || 1
+      cw.falls.push({
+        river: r.id, z: (z0 + z1) / 2, a: (a0 + a1) / 2, dz: (z1 - z0) / len, da: (a1 - a0) / len,
+        topM: top, bottomM: bottom, widthM: 2 * halfWidth[i]!, speedMS: speed[i]!,
+      })
+    }
+
     const c: CRiver = {
-      id: r.id, line, level, halfWidth, bed, flow, reach,
+      id: r.id, line, level, wallLevel, halfWidth, bed, flow, speed, foam, fallAt, reach,
       box: grow(line.box, reach),
       outlet: o ? ('basin' in o ? o.basin.id : o.river.id) : null,
     }
@@ -372,6 +471,35 @@ function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener)
   }
 }
 
+/**
+ * A river drawn from the middle of a lake, or into one, starts and ends at the water's
+ * edge. v1 drew the Long Water from the centre of Hollow Tarn; kept whole, its channel
+ * was cut across the lake floor and its water drawn inside the lake a few metres off the
+ * lake's level, a stripe across the tarn (the author saw it).
+ *
+ * The edge is found by sampling the water itself, not the outline: a tarn's water fills
+ * only as much of its bowl as lies below its level, about the inner 70%, and trimmed to
+ * a guess the river stopped short of it. The river keeps one point inside the water, so
+ * its channel runs into the lake rather than stopping at the shore.
+ */
+function trimToShores(line: Line, basins: CBasin[], cw: CompiledWorld): Line {
+  const n = line.pts.length / 2
+  if (n < 3 || !basins.length) return line
+  const rt = prepare({ ...cw, rivers: [] })
+  const wet = (i: number): boolean => {
+    const s = rt.sample(line.pts[2 * i + 1]! / RADIUS_M, line.pts[2 * i]!, 20, Stage.Basins)
+    return s.waterMesh
+  }
+  let from = 0, to = n - 1
+  if (wet(0)) {
+    while (from < n - 2 && wet(from + 1)) from++
+  }
+  if (wet(n - 1)) {
+    while (to > from + 1 && wet(to - 1)) to--
+  }
+  return from === 0 && to === n - 1 ? line : sliceLine(line, from, to)
+}
+
 /**
  * The non-increasing sequence nearest `y`, by pool adjacent violators.
  *
diff --git a/src/terrain/falls.ts b/src/terrain/falls.ts
new file mode 100644
index 0000000..92427b7
--- /dev/null
+++ b/src/terrain/falls.ts
@@ -0,0 +1,81 @@
+/**
+ * Waterfall sheets. See world-generation.md §4.3 and design.md §1.9.2.
+ *
+ * A fall is where a river's surface steps down (compile.ts). This builds the falling
+ * water: a sheet leaving the lip at the river's speed, dropping under gravity, and —
+ * because everything that falls in Tower carries the spin — drifting **antispinward** as
+ * it goes, by exactly the free-fall drift canon/motion.ts computes. A kilometre-high fall
+ * lands 133 m antispinward of the lip. The waterfall is the compass (§1.9.2); this is the
+ * first time the view draws one.
+ *
+ * Pure geometry, no Three.js, so the lean can be tested against the canon it claims.
+ */
+import { RADIUS_M } from '../canon/constants.js'
+import { freeFallDriftM } from '../canon/motion.js'
+import type { CFall } from './compile.js'
+
+export interface FallSheet {
+  /** The lip's centre, km, tower frame: where the mesh is placed. */
+  origin: [number, number, number]
+  /** Vertices relative to the origin, km. */
+  positions: Float32Array
+  /** u across the sheet 0..1, v down it 0..1. */
+  uvs: Float32Array
+  indices: Uint16Array
+  heightM: number
+}
+
+export const FALL_ROWS = 32
+export const FALL_COLS = 6
+
+/**
+ * Where a drop of water that left the lip at `offset` metres across it is, `y` metres
+ * down: in (z, a) on the unrolled floor and in elevation.
+ */
+export function fallPoint(f: CFall, offset: number, y: number): { z: number; a: number; elevationM: number } {
+  const t = Math.sqrt((2 * y) / 9.80665)
+  const forward = f.speedMS * t
+  // Across the flow: (dz, da) turned a quarter to spinward.
+  const acrossZ = -f.da, acrossA = f.dz
+  return {
+    z: f.z + f.dz * forward + acrossZ * offset,
+    // Antispinward is -a: the drift is subtracted.
+    a: f.a + f.da * forward + acrossA * offset - freeFallDriftM(y),
+    elevationM: f.topM - y,
+  }
+}
+
+export function fallSheet(f: CFall): FallSheet {
+  const height = f.topM - f.bottomM
+  const cos = Math.cos(f.a / RADIUS_M), sin = Math.sin(f.a / RADIUS_M)
+  const r0 = (RADIUS_M - f.topM) / 1000
+  const origin: [number, number, number] = [r0 * cos, r0 * sin, f.z / 1000]
+  const positions = new Float32Array((FALL_ROWS + 1) * (FALL_COLS + 1) * 3)
+  const uvs = new Float32Array((FALL_ROWS + 1) * (FALL_COLS + 1) * 2)
+  let k = 0
+  for (let row = 0; row <= FALL_ROWS; row++) {
+    // Rows bunched toward the lip, where the sheet curves most.
+    const v = (row / FALL_ROWS) ** 1.6
+    for (let col = 0; col <= FALL_COLS; col++) {
+      const u = col / FALL_COLS
+      const p = fallPoint(f, (u - 0.5) * f.widthM, v * height)
+      const theta = p.a / RADIUS_M
+      const r = (RADIUS_M - p.elevationM) / 1000
+      positions[3 * k] = r * Math.cos(theta) - origin[0]
+      positions[3 * k + 1] = r * Math.sin(theta) - origin[1]
+      positions[3 * k + 2] = p.z / 1000 - origin[2]
+      uvs[2 * k] = u
+      uvs[2 * k + 1] = v
+      k++
+    }
+  }
+  const idx: number[] = []
+  for (let row = 0; row < FALL_ROWS; row++) {
+    for (let col = 0; col < FALL_COLS; col++) {
+      const a = row * (FALL_COLS + 1) + col
+      const b = a + 1, c = a + FALL_COLS + 1, d = c + 1
+      idx.push(a, c, b, b, c, d)
+    }
+  }
+  return { origin, positions, uvs, indices: new Uint16Array(idx), heightM: height }
+}
diff --git a/src/terrain/field.ts b/src/terrain/field.ts
index 57ab746..8b13028 100644
--- a/src/terrain/field.ts
+++ b/src/terrain/field.ts
@@ -48,6 +48,20 @@ export interface Sample {
    * dots where its vertices happened to land in the water.
    */
   waterCover: number
+  /**
+   * True where the water here is wide enough for a tile to draw it as a surface of its
+   * own. A river narrower than the tile's vertex spacing is not: it shows only as
+   * `waterCover`, a tint on the ground.
+   */
+  waterMesh: boolean
+  /** The sea state of the water here: main wave amplitude and length, and its direction. */
+  waveAmpM: number
+  waveLengthM: number
+  waveDirRad: number
+  /** River flow here, m/s along z and around, and how white it is. */
+  flowZ: number
+  flowA: number
+  foam: number
   /** 0..1 claims on the surface. The shader turns these into colour. */
   desert: number
   forest: number
@@ -93,7 +107,11 @@ function warp(seed: number, p: CylPoint, z: number, a: number, ampM: number, sca
  */
 export class Runtime {
   private indexes: LineIndex[]
-  private out: Sample = { h: 0, water: -Infinity, waterCover: 0, desert: 0, forest: 0, relief: 0, snowlineM: 3000 }
+  private out: Sample = {
+    h: 0, water: -Infinity, waterCover: 0, waterMesh: false,
+    waveAmpM: 0, waveLengthM: 0, waveDirRad: 0, flowZ: 0, flowA: 0, foam: 0,
+    desert: 0, forest: 0, relief: 0, snowlineM: 3000,
+  }
   private nearest: Nearest = { d: 0, seg: 0, t: 0 }
 
   constructor(readonly world: CompiledWorld) {
@@ -111,6 +129,13 @@ export class Runtime {
     const p = cylPoint(theta, z)
     o.water = -Infinity
     o.waterCover = 0
+    o.waterMesh = false
+    o.waveAmpM = 0
+    o.waveLengthM = 0
+    o.waveDirRad = 0
+    o.flowZ = 0
+    o.flowA = 0
+    o.foam = 0
     o.desert = 0
     o.forest = 0
     o.relief = 0
@@ -143,7 +168,7 @@ export class Runtime {
     for (const b of w.basins) if (inBox(b.box, z, a)) basin(b, p, z, a, detailM, o)
     if (upTo === Stage.Basins) return o
     w.rivers.forEach((r, i) => {
-      if (inBox(r.box, z, a)) river(r, this.indexes[i]!, z, a, detailM, o, this.nearest)
+      if (inBox(r.box, z, a)) river(r, this.indexes[i]!, p, z, a, detailM, o, this.nearest)
     })
     return o
   }
@@ -236,6 +261,20 @@ function mountain(m: CMountainGroup, p: CylPoint, z: number, a: number, detailM:
   return Math.max(0, r + detail * clear)
 }
 
+/** A lake or sea's water, where it is the highest water here. */
+function setBasinWater(b: CBasin, o: Sample): void {
+  if (b.levelM < o.water) return
+  o.water = b.levelM
+  o.waterMesh = true
+  o.waterCover = 1
+  o.waveAmpM = b.waveAmpM
+  o.waveLengthM = b.waveLengthM
+  o.waveDirRad = b.waveDirRad
+  o.flowZ = 0
+  o.flowA = 0
+  o.foam = 0
+}
+
 /** Carve a lake or sea basin and set its water where the floor is below its level. */
 function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void {
   if (b.basin === 'flooded') {
@@ -252,7 +291,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o:
       : smoothstep(0, b.shoreM, sd)
     if (m <= 0.001) return
     o.h -= b.depthM * m ** 0.7
-    if (m > 0.05 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterCover = 1 }
+    if (m > 0.05 && o.h < b.levelM) setBasinWater(b, o)
     return
   }
   const sd = signedDistance(b.shape, z, a)
@@ -260,7 +299,7 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o:
   const d = 1 - Math.min(1, sd / b.maxInsideM)
   o.h -= b.depthM * Math.cos((d * Math.PI) / 2) ** 1.2
   if (d > 0.68) o.h += b.rimM * Math.sin(((d - 0.68) / 0.32) * Math.PI)
-  if (d < 0.85 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterCover = 1 }
+  if (d < 0.85 && o.h < b.levelM) setBasinWater(b, o)
 }
 
 /**
@@ -272,25 +311,77 @@ function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o:
  * and a canyon where it crosses high ground. An embankment is the same idea the other
  * way up — flanks falling away at a fixed slope until they meet the ground.
  */
-function river(r: CRiver, index: LineIndex, z: number, a: number, detailM: number, o: Sample, near: Nearest): void {
+function river(r: CRiver, index: LineIndex, p: CylPoint, z: number, a: number, detailM: number, o: Sample, near: Nearest): void {
   const segs = index.near(z, a)
   if (!segs.length) return
   nearestOnSegments(r.line, z, a, segs, near)
   if (near.d > r.reach) return
   const i = near.seg, t = near.t
   const lerp = (arr: number[]): number => arr[i]! + (arr[i + 1]! - arr[i]!) * t
-  const level = lerp(r.level)
+  /**
+   * At a waterfall the surface does not slope across the segment: it holds its level to
+   * the middle and steps. A few metres of easing keep the cliff from being a knife-edge
+   * that no tile could sample honestly.
+   */
+  const fall = r.fallAt.includes(i)
+  const level = fall
+    ? r.level[i]! + (r.level[i + 1]! - r.level[i]!) * smoothstep(0.5, 0.5 + 6 / Math.max(1, segLength(r, i)), t)
+    : lerp(r.level)
   const hw = lerp(r.halfWidth)
   const bed = lerp(r.bed)
   const x = near.d - hw
   const floor = level - bed * (1 - Math.min(1, near.d / hw) ** 2) - 0.2
-  const cut = x <= 0 ? floor : level + 0.08 * x + (x * x) / 6000
-  // The embankment's crest is the bank, a metre above the water,
-  // with a few metres of level top before the flanks fall away.
-  const fill = x <= 0 ? floor : level + 1 - Math.max(0, x - 25) * EMBANKMENT_SLOPE
+  /**
+   * Beyond the channel the ground rises along the valley's own wall shape, measured from
+   * the wall level — which ramps smoothly through a waterfall — and never higher than a
+   * steep gorge wall rising from the water. Away from falls the two levels are the same
+   * and the valley shape is the lower of the two everywhere; below a fall the gorge is,
+   * close in, so the river runs in a slot and the valley walls stand back unbroken.
+   */
+  const wall = lerp(r.wallLevel)
+  let gorge = Infinity
+  if (wall - level > 1) {
+    // Only where there is a gorge, since it costs a noise call: its walls wander, so the
+    // slot below a fall reads as rock that broke where it broke rather than as a cutting.
+    const jitter = 1 + 0.35 * fbm(GORGE_SEED, p, 420, 3, detailM)
+    // The same curvature as the valley's walls, so once the gorge wall has risen past
+    // the valley's shape it stays above it. With only the steep straight term, the
+    // valley's x² outgrew it a few kilometres out and a second cliff was cut there.
+    gorge = level + GORGE_WALL * x * jitter + (x * x) / 6000
+  }
+  const cut = x <= 0 ? floor : Math.min(gorge, wall + 0.08 * x + (x * x) / 6000)
+  // The embankment's crest is the bank, a metre above the water, with a few metres of
+  // level top before the flanks fall away. It is shaped from the wall level too, so an
+  // embankment carrying a river to a fall ramps down beside the gorge rather than ending
+  // in a cliff the whole width of the embankment.
+  const fill = x <= 0 ? floor : wall + 1 - Math.max(0, x - 25) * EMBANKMENT_SLOPE
   o.h = smin(-smin(-o.h, -fill, 12), cut, 12)
-  if (x <= 0 && level > o.h) o.water = Math.max(o.water, level)
   // A sample stands for a cell about half the detail wavelength across (tile.ts).
   const cell = Math.max(1, detailM / 2)
-  if (near.d < hw + cell / 2) o.waterCover = Math.max(o.waterCover, Math.min(1, (2 * hw) / cell))
+  const drawn = 2 * hw >= cell
+  if (x <= 0 && level > o.h && level >= o.water) {
+    o.water = level
+    if (drawn) {
+      o.waterMesh = true
+      o.waterCover = 1
+      const len = segLength(r, i)
+      const speed = lerp(r.speed)
+      o.flowZ = ((r.line.pts[2 * i + 2]! - r.line.pts[2 * i]!) / len) * speed
+      o.flowA = ((r.line.pts[2 * i + 3]! - r.line.pts[2 * i + 1]!) / len) * speed
+      o.foam = Math.max(lerp(r.foam), fall ? 1 : 0)
+      // A river's own chop: short and low, and running with it.
+      o.waveAmpM = 0.04
+      o.waveLengthM = 3
+      o.waveDirRad = Math.atan2(o.flowA, o.flowZ)
+    }
+  }
+  if (!drawn && near.d < hw + cell / 2) o.waterCover = Math.max(o.waterCover, (2 * hw) / cell)
+}
+
+/** Rise per metre across of a gorge's walls: about 50°. */
+const GORGE_WALL = 1.2
+const GORGE_SEED = 0x60e5
+
+function segLength(r: CRiver, i: number): number {
+  return Math.hypot(r.line.pts[2 * i + 2]! - r.line.pts[2 * i]!, r.line.pts[2 * i + 3]! - r.line.pts[2 * i + 1]!) || 1
 }
diff --git a/src/terrain/map-image.ts b/src/terrain/map-image.ts
index 1df5b6a..11b475e 100644
--- a/src/terrain/map-image.ts
+++ b/src/terrain/map-image.ts
@@ -37,7 +37,12 @@ export function renderMapRows(
     for (let x = 0; x < width; x++) {
       const s = rt.sample(theta, (x + 0.5) * mppX, detail)
       shown[r * width + x] = Math.max(s.h, s.water)
-      if (r >= 1 && r <= y1 - y0) materialsAt(s, 0, mats, ((r - 1) * width + x) * MATERIALS.length)
+      if (r >= 1 && r <= y1 - y0) {
+        const o = ((r - 1) * width + x) * MATERIALS.length
+        materialsAt(s, 0, mats, o)
+        // The map has no water surfaces to draw over the ground: the water is the colour.
+        if (s.waterMesh) { mats[o + 5] = 1; mats[o + 7] = 0 }
+      }
     }
   }
 
diff --git a/src/terrain/service.ts b/src/terrain/service.ts
index e944c0d..7e7fdcc 100644
--- a/src/terrain/service.ts
+++ b/src/terrain/service.ts
@@ -13,6 +13,7 @@
 import { type Remote, proxy, wrap } from 'comlink'
 import { mapThetaOrigin } from '../canon/geometry.js'
 import { type World, terrainSignature } from '../world/schema.js'
+import { cacheKey, loadCompiled, saveCompiled } from './cache.js'
 import type { CompiledWorld } from './compile.js'
 import { type Runtime, prepare } from './field.js'
 import type { TileData, TileGrid, TileKey } from './tile.js'
@@ -58,13 +59,31 @@ export class TerrainService {
   private mapListeners = new Set<Listener>()
   private progressListeners = new Set<Listener>()
 
+  /** The compiled world was read from the cache last time rather than compiled. */
+  lastFromCache = false
+  private readonly workerUrl = new URL('./worker.ts', import.meta.url)
+  /**
+   * Whether the compiled world may be cached. In a build the worker's URL carries a hash
+   * of its code, so a new build can never read an old build's world. In dev the URL never
+   * changes while the code does, so the cache is off there — unless a page asks for it
+   * with `?cache=1`, as the screenshot tour does from a browser it starts fresh.
+   */
+  private readonly caching: boolean
+
   constructor() {
+    const params = new URLSearchParams(location.search)
     const cores = navigator.hardwareConcurrency || 4
-    const size = Math.max(1, Math.min(6, cores - 1))
+    /**
+     * `?workers=N` caps the pool. The screenshot tour runs several pages at once, and a
+     * full pool in each is two dozen threads fighting for the same cores.
+     */
+    const cap = Number(params.get('workers'))
+    const size = Math.max(1, Math.min(Number.isFinite(cap) && cap > 0 ? cap : 6, cores - 1))
     this.slots = Array.from({ length: size }, () => ({
-      api: wrap<WorkerApi>(new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })),
+      api: wrap<WorkerApi>(new Worker(this.workerUrl, { type: 'module' })),
       inFlight: 0,
     }))
+    this.caching = !import.meta.env.DEV || params.get('cache') === '1'
   }
 
   get workers(): number {
@@ -134,9 +153,15 @@ export class TerrainService {
     let done = 0
     this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() })
 
-    const compiled = await this.slots[0]!.api.compile(world, proxy((label: string) => {
-      this.setProgress({ label, done: done++ })
-    }))
+    const key = cacheKey(`${this.workerUrl.href}\n${terrainSignature(world)}`)
+    let compiled = this.caching ? await loadCompiled(key) : null
+    this.lastFromCache = compiled !== null
+    if (!compiled) {
+      compiled = await this.slots[0]!.api.compile(world, proxy((label: string) => {
+        this.setProgress({ label, done: done++ })
+      }))
+      if (this.caching) void saveCompiled(key, compiled)
+    }
 
     await Promise.all(this.slots.map((s) => s.api.load(compiled, gen)))
     this.compiled = compiled
diff --git a/src/terrain/shapes.ts b/src/terrain/shapes.ts
index 4a81c93..b869a6f 100644
--- a/src/terrain/shapes.ts
+++ b/src/terrain/shapes.ts
@@ -209,3 +209,11 @@ export class LineIndex {
     return this.cells.get(Math.floor(z / this.cell) * 100_003 + Math.floor(q / this.cell)) ?? []
   }
 }
+
+/** Points `from`..`to` of a line, inclusive, with distances measured from the new start. */
+export function sliceLine(line: Line, from: number, to: number): Line {
+  const pts = line.pts.slice(2 * from, 2 * to + 2)
+  const s0 = line.s[from]!
+  const s = line.s.slice(from, to + 1).map((v) => v - s0)
+  return { pts, s, box: boxOf(pts) }
+}
diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts
index 4fb1853..23a41e2 100644
--- a/src/terrain/tile.ts
+++ b/src/terrain/tile.ts
@@ -52,7 +52,20 @@ export function tileCentreKm(t: TileKey): [number, number, number] {
  * shader turns them into colour, so a tile carries what the ground *is* and the look
  * of it can change without regenerating anything.
  */
-export const MATERIALS = ['plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'unused'] as const
+export const MATERIALS = ['plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed'] as const
+
+/**
+ * Per water vertex, in this order: depth of water over the ground, main wave amplitude,
+ * wavelength and direction; flow along z and around; whiteness; spare.
+ */
+export const WATER_ATTRS = 8
+
+export interface TileWater {
+  /** Water surface vertices, relative to the tile centre, km: the same grid as the ground. */
+  positions: Float32Array
+  attrs: Float32Array
+  indices: Uint16Array
+}
 
 export interface TileData {
   key: string
@@ -62,7 +75,9 @@ export interface TileData {
   normals: Int8Array
   /** MATERIALS.length weights per vertex, 0..255. */
   materials: Uint8Array
-  /** Lowest and highest displayed elevation on the tile, metres. */
+  /** The water surfaces on this tile, or null where there is none. */
+  water: TileWater | null
+  /** Lowest and highest displayed elevation on the tile, ground or water, metres. */
   minM: number
   maxM: number
 }
@@ -83,7 +98,15 @@ const smoothstep = (e0: number, e1: number, x: number): number => {
  */
 export function materialsAt(s: Sample, slope: number, out: Float32Array | number[], offset = 0): void {
   const shown = Math.max(s.h, s.water)
-  const water = s.waterCover
+  /**
+   * Water that is drawn as a surface of its own leaves the ground under it as bed. Water
+   * too narrow to draw — a river under a coarse tile's vertex spacing — tints the ground
+   * by how much of it the river covers.
+   */
+  // A narrow river's share of a cell reads far weaker than a line that thin looks to the
+  // eye: 40 m of river in a 200 m cell is a 20% tint, and it vanished where tiles coarsened.
+  // The square root keeps a sub-resolution river continuous with the surface drawn nearer.
+  const water = s.waterMesh ? 0 : Math.min(1, Math.sqrt(s.waterCover) * 1.2)
   const rock = smoothstep(1300, 1900, s.relief)
   const desert = s.desert * (1 - rock)
   const forest = s.forest * (1 - rock) * (1 - desert)
@@ -100,7 +123,7 @@ export function materialsAt(s: Sample, slope: number, out: Float32Array | number
   out[offset + 5] = water
   // Water deepens to its darkest over the first 90 m, as the v1 map did.
   out[offset + 6] = s.water > s.h ? Math.min(1, (s.water - s.h) / 90) : 0
-  out[offset + 7] = 0
+  out[offset + 7] = s.waterMesh ? 1 : 0
 }
 
 /**
@@ -120,6 +143,10 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
 
   const shown = new Float64Array(W * W)
   const mats = new Float32Array(W * W * MATERIALS.length)
+  // The water on the tile's own grid: its level (NaN where none is drawn) and attributes.
+  const wLevel = new Float64Array(n * n).fill(NaN)
+  const wAttrs = new Float32Array(n * n * WATER_ATTRS)
+  const wet = new Uint8Array(n * n)
   for (let v = 0; v < W; v++) {
     for (let u = 0; u < W; u++) {
       // z is clamped to the world: the ring beyond a tile at either cap samples the
@@ -128,7 +155,21 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
       const theta = (a0 + (v - 1) * da) / RADIUS_M
       const s = rt.sample(theta, z, detail)
       const k = v * W + u
-      shown[k] = Math.max(s.h, s.water)
+      // The ground is the ground, under water or not: the water is its own mesh now.
+      shown[k] = s.h
+      if (s.waterMesh && u >= 1 && u <= n && v >= 1 && v <= n) {
+        const w = (v - 1) * n + (u - 1)
+        wet[w] = 1
+        wLevel[w] = s.water
+        const o = w * WATER_ATTRS
+        wAttrs[o] = s.water - s.h
+        wAttrs[o + 1] = s.waveAmpM
+        wAttrs[o + 2] = s.waveLengthM
+        wAttrs[o + 3] = s.waveDirRad
+        wAttrs[o + 4] = s.flowZ
+        wAttrs[o + 5] = s.flowA
+        wAttrs[o + 6] = s.foam
+      }
       // Slope is not known until the neighbours are, so snow is thinned by it below.
       materialsAt(s, 0, mats, k * MATERIALS.length)
     }
@@ -187,14 +228,88 @@ export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData {
    * neighbour could open. Deep enough for the height error one level coarser, which
    * scales with the tile, and never less than a few metres.
    */
-  const drop = Math.max(8, 0.04 * Math.max(size.along, size.around))
+  // On steep ground a coarser neighbour's edge can be off by far more than 4% of the
+  // tile: as much as the steepest step between two vertices here, and then some. A 50°
+  // gorge wall showed its cracks as a row of dark panels until skirts reached that far.
+  let steepest = 0
+  for (let v = 1; v <= n; v++) {
+    for (let u = 1; u <= n; u++) {
+      const k = v * W + u
+      steepest = Math.max(steepest, Math.abs(shown[k]! - shown[k + 1]!), Math.abs(shown[k]! - shown[k + W]!))
+    }
+  }
+  const drop = Math.max(8, 0.04 * Math.max(size.along, size.around), 2 * steepest)
   let idx = n * n
   for (let e = 0; e < n; e++) write(idx++, e, 0, drop)
   for (let e = 0; e < n; e++) write(idx++, n - 1, e, drop)
   for (let e = 0; e < n; e++) write(idx++, n - 1 - e, n - 1, drop)
   for (let e = 0; e < n; e++) write(idx++, 0, n - 1 - e, drop)
 
-  return { key: tileKey(t), positions, normals, materials, minM, maxM }
+  const water = buildWater(n, z0, a0, dz, da, [cx, cy, cz], wLevel, wAttrs, wet)
+  if (water) {
+    for (let w = 0; w < n * n; w++) {
+      if (Number.isFinite(wLevel[w]!)) { minM = Math.min(minM, wLevel[w]!); maxM = Math.max(maxM, wLevel[w]!) }
+    }
+  }
+  return { key: tileKey(t), positions, normals, materials, water, minM, maxM }
+}
+
+/**
+ * The water surface for a tile. Every wet vertex carries its water's level; a dry vertex
+ * next to one borrows that level, so the surface runs a little way under the bank and the
+ * depth test draws the shoreline exactly where water meets ground. A triangle is kept if
+ * all three corners have a level and at least one is wet.
+ */
+function buildWater(
+  n: number, z0: number, a0: number, dz: number, da: number, centre: [number, number, number],
+  level: Float64Array, attrs: Float32Array, wet: Uint8Array,
+): TileWater | null {
+  if (!wet.some((w) => w)) return null
+  for (let v = 0; v < n; v++) {
+    for (let u = 0; u < n; u++) {
+      const w = v * n + u
+      if (wet[w]) continue
+      let best = -Infinity, from = -1
+      for (let dv = -1; dv <= 1; dv++) {
+        for (let du = -1; du <= 1; du++) {
+          const uu = u + du, vv = v + dv
+          if (uu < 0 || vv < 0 || uu >= n || vv >= n) continue
+          const q = vv * n + uu
+          if (wet[q] && level[q]! > best) { best = level[q]!; from = q }
+        }
+      }
+      if (from < 0) continue
+      level[w] = best
+      attrs.copyWithin(w * WATER_ATTRS, from * WATER_ATTRS, (from + 1) * WATER_ATTRS)
+      attrs[w * WATER_ATTRS] = 0 // no depth on the bank
+    }
+  }
+  const positions = new Float32Array(n * n * 3)
+  const [cx, cy, cz] = centre
+  for (let v = 0; v < n; v++) {
+    const theta = (a0 + v * da) / RADIUS_M
+    const cos = Math.cos(theta), sin = Math.sin(theta)
+    for (let u = 0; u < n; u++) {
+      const w = v * n + u
+      if (!Number.isFinite(level[w]!)) continue
+      const r = (RADIUS_M - level[w]!) / 1000
+      const z = Math.min(LENGTH_M, Math.max(0, z0 + u * dz))
+      positions[3 * w] = r * cos - cx
+      positions[3 * w + 1] = r * sin - cy
+      positions[3 * w + 2] = z / 1000 - cz
+    }
+  }
+  const idx: number[] = []
+  const has = (w: number): boolean => Number.isFinite(level[w]!)
+  for (let v = 0; v < n - 1; v++) {
+    for (let u = 0; u < n - 1; u++) {
+      const a = v * n + u, b = a + 1, c = a + n, d = c + 1
+      if (has(a) && has(b) && has(c) && (wet[a] || wet[b] || wet[c])) idx.push(a, b, c)
+      if (has(b) && has(d) && has(c) && (wet[b] || wet[d] || wet[c])) idx.push(b, d, c)
+    }
+  }
+  if (!idx.length) return null
+  return { positions, attrs, indices: new Uint16Array(idx) }
 }
 
 const indexCache = new Map<number, Uint16Array>()
diff --git a/src/terrain/water.ts b/src/terrain/water.ts
new file mode 100644
index 0000000..b82ecf8
--- /dev/null
+++ b/src/terrain/water.ts
@@ -0,0 +1,91 @@
+/**
+ * The physics of the water: how big the waves are, and how fast a river runs.
+ * See world-generation.md §4.2–§4.4.
+ *
+ * None of this is chosen for looks. Waves come from the wind and the distance it has
+ * blown over water, by the relations oceanographers fit to measured seas; a river's
+ * speed comes from how much it carries and how steeply it falls. Gravity at the floor
+ * is 1 g (design.md §1.2), so Earth's numbers apply unchanged.
+ */
+import { CIRCUMFERENCE_M, G0 } from '../canon/constants.js'
+import type { Shape } from './shapes.js'
+
+export interface WindSea {
+  /** Significant wave height: the mean of the highest third, crest to trough. */
+  heightM: number
+  /** Period at the spectrum's peak. */
+  periodS: number
+  /** Deep-water wavelength at that period. */
+  lengthM: number
+}
+
+/**
+ * The sea a wind raises. Fetch-limited by the JONSWAP relations —
+ *
+ *     g·Hs / U² = 0.0016 · (g·F / U²)^½
+ *     g·Tp / U  = 0.286  · (g·F / U²)^⅓
+ *
+ * — and never past the fully developed sea of Pierson and Moskowitz, Hs = 0.21·U²/g,
+ * which is where a wind that has blown far enough stops building waves. A ring sea's
+ * wind can blow round the whole world and back over the same water, so its sea is
+ * always fully developed (§4.4): 2.1 m at 10 m/s, 8.6 m at 20.
+ */
+export function windSea(windMS: number, fetchM: number): WindSea {
+  const u = Math.max(0, windMS)
+  if (u < 0.1) return { heightM: 0, periodS: 0, lengthM: 0 }
+  const x = (G0 * fetchM) / (u * u)
+  const fetchHeight = (0.0016 * Math.sqrt(x) * u * u) / G0
+  const fetchPeriod = (0.286 * Math.cbrt(x) * u) / G0
+  const fullHeight = (0.21 * u * u) / G0
+  // Pierson–Moskowitz peak frequency, ωp = 0.877 g / U.
+  const fullPeriod = (2 * Math.PI * u) / (0.877 * G0)
+  const heightM = Math.min(fetchHeight, fullHeight)
+  const periodS = Math.min(fetchPeriod, fullPeriod)
+  return { heightM, periodS, lengthM: (G0 * periodS * periodS) / (2 * Math.PI) }
+}
+
+/**
+ * How far the wind blows over a body of water, in the direction it blows: `dirRad` is
+ * measured from deepward toward spinward, the interior view's heading convention.
+ *
+ * Around a band there is no end — the water closes on itself — so any wind with a
+ * circumferential component has, for waves, unlimited fetch; along the axis it has the
+ * band's width. A polygon's fetch is its extent in the wind's direction.
+ */
+export function fetchOf(shape: Shape, dirRad: number): number {
+  const along = Math.abs(Math.cos(dirRad))
+  const around = Math.abs(Math.sin(dirRad))
+  if (shape.type === 'band') {
+    if (around > 0.2) return Infinity
+    return (shape.zTo - shape.zFrom) / Math.max(along, 1e-3)
+  }
+  const b = shape.box
+  return Math.max(10, Math.hypot((b.z1 - b.z0) * along, (b.a1 - b.a0) * around))
+}
+
+/** Longest a wind can blow around Tower before meeting its own waves: the circumference. */
+export const RING_FETCH_M = CIRCUMFERENCE_M
+
+/**
+ * Mean speed of a river, by Manning's equation: v = R^⅔ · S^½ / n. The hydraulic
+ * radius of a wide channel is about its depth, and n = 0.035 is a natural channel
+ * with some stones and weeds. Clamped to what rivers actually do: a pool still moves
+ * a little, and nothing flows faster than about 8 m/s outside a spillway.
+ */
+export function manningSpeed(depthM: number, grade: number): number {
+  const v = (Math.max(0.05, depthM) ** (2 / 3) * Math.sqrt(Math.max(0, grade))) / MANNING_N
+  return Math.min(8, Math.max(0.15, v))
+}
+export const MANNING_N = 0.035
+
+/**
+ * How white a reach is. Rivers break into rapids from a few percent of grade, and are
+ * all foam well before they fall.
+ */
+export function foamOf(grade: number): number {
+  const t = Math.min(1, Math.max(0, (grade - 0.025) / (0.12 - 0.025)))
+  return t * t * (3 - 2 * t)
+}
+
+/** A drop of at least this many metres in one step of a river's course is a waterfall. */
+export const FALL_MIN_M = 12
diff --git a/src/terrain/worker.ts b/src/terrain/worker.ts
index 2d16544..c323dde 100644
--- a/src/terrain/worker.ts
+++ b/src/terrain/worker.ts
@@ -29,7 +29,9 @@ const api = {
   tile(gen: number, t: TileKey, n: TileGrid): TileData | null {
     if (!runtime || gen !== generation) return null
     const data = buildTile(runtime, t, n)
-    return transfer(data, [data.positions.buffer, data.normals.buffer, data.materials.buffer])
+    const buffers: Transferable[] = [data.positions.buffer, data.normals.buffer, data.materials.buffer]
+    if (data.water) buffers.push(data.water.positions.buffer, data.water.attrs.buffer, data.water.indices.buffer)
+    return transfer(data, buffers)
   },
 
   mapRows(gen: number, width: number, height: number, y0: number, y1: number, origin: number): Uint8ClampedArray | null {
diff --git a/src/ui/app.ts b/src/ui/app.ts
index eed29a0..b04ef2e 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -719,6 +719,47 @@ export class App {
       patch({ public: (e.target as HTMLInputElement).checked })
     $('f-delete').onclick = () => this.deleteSelected()
     this.wireMountains()
+    this.wireWater()
+  }
+
+  /** The wind over a lake or sea, and what sea it raises there. */
+  private wireWater(): void {
+    const body = () => {
+      const f = this.selectedId ? this.store.get(this.selectedId) : undefined
+      return f?.kind === 'lake' || f?.kind === 'sea' ? f : null
+    }
+    const dir = $<HTMLInputElement>('f-wind-dir')
+    dir.onchange = () => {
+      const f = body()
+      const v = Number(dir.value)
+      if (f && Number.isFinite(v)) this.store.update(f.id, { wind: { ...f.wind, directionDeg: ((v % 360) + 360) % 360 } })
+    }
+    const speed = $<HTMLInputElement>('f-wind-speed')
+    speed.oninput = () => { $('f-wind-speed-value').textContent = `— ${speed.value} m/s` }
+    speed.onchange = () => {
+      const f = body()
+      if (f) this.store.update(f.id, { wind: { ...f.wind, speedMS: Number(speed.value) } })
+    }
+    // The sea state is read off the compiled world, so it says what was actually generated.
+    terrain().onWorld(() => this.renderWater(this.selectedId ? this.store.get(this.selectedId) : undefined))
+  }
+
+  private renderWater(f: Feature | undefined): void {
+    const box = $('f-water')
+    box.hidden = f?.kind !== 'lake' && f?.kind !== 'sea'
+    if (f?.kind !== 'lake' && f?.kind !== 'sea') return
+    const dir = $<HTMLInputElement>('f-wind-dir')
+    if (document.activeElement !== dir) dir.value = String(f.wind.directionDeg)
+    const speed = $<HTMLInputElement>('f-wind-speed')
+    if (document.activeElement !== speed) speed.value = String(f.wind.speedMS)
+    $('f-wind-speed-value').textContent = `— ${f.wind.speedMS} m/s`
+    const b = terrain().compiled?.basins.find((x) => x.id === f.id)
+    $('f-sea-state').textContent = !b ? '' : b.waveAmpM <= 0
+      ? 'Flat calm.'
+      : `Waves ${(b.waveAmpM / 0.35).toFixed(1)} m high, ${b.waveLengthM.toFixed(0)} m apart, ` +
+        `every ${Math.sqrt((2 * Math.PI * b.waveLengthM) / 9.80665).toFixed(1)} s.` +
+        (f.kind === 'sea' && Math.abs(Math.sin((f.wind.directionDeg * Math.PI) / 180)) > 0.2
+          ? ' The wind runs round the ring, so the sea is fully developed.' : '')
   }
 
   /**
@@ -999,6 +1040,7 @@ export class App {
     if (document.activeElement !== notes) notes.value = p.notes
     $<HTMLInputElement>('f-public').checked = p.public
     this.renderMountains(p)
+    this.renderWater(p)
 
     $('f-where').textContent = this.whereText(p)
   }
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index 6d3f6e2..8caf698 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -12,10 +12,10 @@
  *
  * Scene units are kilometres, so the numbers in the code are the numbers in canon.
  */
-import type { Group, Object3D, Texture } from 'three'
+import type { Object3D, Texture } from 'three'
 import {
-  AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide,
-  FrontSide, Matrix4, Mesh, RingGeometry, ShaderMaterial, Vector3,
+  AdditiveBlending, BackSide, BufferAttribute, BufferGeometry, CircleGeometry, Color, CylinderGeometry,
+  DoubleSide, FrontSide, Group, Matrix4, Mesh, RingGeometry, ShaderMaterial, Vector3,
 } from 'three'
 import {
   BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M,
@@ -24,6 +24,7 @@ import {
 } from '../canon/constants.js'
 import { PALETTE_GLSL } from '../terrain/palette.js'
 import { LOWEST_GROUND_M } from '../terrain/tile.js'
+import { fallSheet } from '../terrain/falls.js'
 import { terrain } from '../terrain/service.js'
 import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js'
 import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js'
@@ -215,6 +216,8 @@ void main() {
   // The palette is sRGB; every uniform colour here is linear. Three inserts that
   // decode into its own materials and never into a custom one.
   vec3 base = pow(groundColour(vMatA, vMatB, elevM), vec3(2.2));
+  // Under drawn water the ground is bed: sediment, darker, whatever the land around it.
+  base = mix(base, vec3(0.16, 0.14, 0.11), vMatB.w * 0.8);
 
   // Unrolled surface coordinates, km. World xyz shears a pattern into radial streaks
   // exactly where the ground is most grazing, which is most of the view.
@@ -263,6 +266,234 @@ void main() {
 }
 `
 
+/**
+ * Water (world-generation.md §4.2–§4.4). Every lake, sea and river is a surface at its own
+ * level, drawn over the ground with the depth test deciding the shoreline.
+ *
+ * Waves are a sum of four Gerstner-like components around the body's wind, sized from
+ * its wind and fetch in the worker (water.ts). They displace the surface only near the
+ * eye, where the vertices are close enough to carry them; everywhere they shade it, and
+ * each component fades out once it is smaller than a few pixels, so distant water is
+ * calm rather than a shimmer of aliasing. A river's flow scrolls ripples downstream.
+ *
+ * The surface coordinates are metres on the unrolled floor, taken from the tower-frame
+ * position, so the pattern is continuous across tiles and around theta.
+ */
+const WAVES_GLSL = /* glsl */`
+uniform float uTime;
+const float G = 9.80665;
+const vec4 WAVE_LEN = vec4(1.0, 0.62, 0.41, 0.27);
+const vec4 WAVE_AMP = vec4(1.0, 0.55, 0.35, 0.22);
+const vec4 WAVE_TURN = vec4(0.0, 0.45, -0.38, 0.85);
+const vec4 WAVE_PHASE = vec4(0.0, 1.7, 4.1, 2.6);
+
+// Height of the wave sum at surface position p (metres), and its gradient, fading each
+// component out below 'footprint' metres of wavelength.
+vec3 waves(vec2 p, float amp, float len, float dir, float footprint) {
+  vec3 out3 = vec3(0.0);
+  if (amp <= 0.0 || len <= 0.0) return out3;
+  for (int i = 0; i < 4; i++) {
+    float L = len * WAVE_LEN[i];
+    float fade = smoothstep(2.0, 8.0, L / max(footprint, 1e-3));
+    if (fade <= 0.0) continue;
+    float a = amp * WAVE_AMP[i] * fade;
+    float th = dir + WAVE_TURN[i];
+    vec2 d = vec2(cos(th), sin(th));
+    float k = 6.2831853 / L;
+    float w = sqrt(G * k);
+    // Time wraps every ten minutes: long enough that nobody sees it, short enough that
+    // float precision in the phase never degrades.
+    float ph = k * dot(d, p) - w * mod(uTime, 600.0) + WAVE_PHASE[i];
+    out3.x += a * cos(ph);
+    out3.yz -= a * k * sin(ph) * d;
+  }
+  return out3;
+}
+
+vec2 surfaceMetres(vec3 world) {
+  return vec2(world.z * 1000.0, atan(world.y, world.x) * ${(R * 1000).toFixed(1)});
+}
+`
+
+const WATER_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+uniform mat4 uFrame;
+uniform vec3 uCam;
+attribute vec4 aWater;
+attribute vec4 aFlow;
+varying vec3 vWorld;
+varying vec4 vWater;
+varying vec4 vFlow;
+${WAVES_GLSL}
+void main() {
+  vec3 world = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
+  // Displace only close in, where vertices are metres apart; beyond, waves are shading.
+  float near = 1.0 - smoothstep(0.3, 2.5, distance(uCam, world));
+  vec3 p = position;
+  if (near > 0.0 && aWater.y > 0.0) {
+    float h = waves(surfaceMetres(world), aWater.y, aWater.z, aWater.w, 0.5).x;
+    // Up is toward the axis. The tile mesh sits in the tower's frame, so its model
+    // matrix is a translation and 'up' can be taken from the world position directly.
+    vec3 up = -normalize(vec3(world.xy, 0.0));
+    p += up * (h * near / 1000.0);
+    world += up * (h * near / 1000.0);
+  }
+  vWorld = world;
+  vWater = aWater;
+  vFlow = aFlow;
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+const WATER_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform float uFrontZ;
+uniform float uFrontWidth;
+uniform float uBelowFront;
+uniform float uAboveFront;
+varying vec3 vWorld;
+varying vec4 vWater;
+varying vec4 vFlow;
+${HAZE_GLSL}
+${WAVES_GLSL}
+
+float wHash(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453); }
+float wNoise(vec2 p) {
+  vec2 i = floor(p), f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  return mix(mix(wHash(i), wHash(i + vec2(1, 0)), f.x), mix(wHash(i + vec2(0, 1)), wHash(i + vec2(1, 1)), f.x), f.y);
+}
+
+void main() {
+  #include <logdepthbuf_fragment>
+  vec2 sp = surfaceMetres(vWorld);
+  // Metres of floor under one pixel: what decides which waves can still be drawn.
+  float footprint = length(fwidth(sp));
+  vec3 radial = normalize(vec3(vWorld.xy, 0.0));
+  vec3 up = -radial;
+  vec3 spinward = vec3(-radial.y, radial.x, 0.0);
+  vec3 deepward = vec3(0.0, 0.0, 1.0);
+
+  vec3 w = waves(sp, vWater.y, vWater.z, vWater.w, footprint);
+  vec2 grad = w.yz;
+
+  /**
+   * Flow: ripples carried downstream. Two copies of a noise field advected by the flow
+   * and cross-faded on a short cycle, so the pattern moves without ever stretching out.
+   */
+  vec2 flow = vFlow.xy;
+  float speed = length(flow);
+  if (speed > 0.01) {
+    float cycle = 2.0;
+    float t = mod(uTime, cycle) / cycle;
+    vec2 q = sp / 1.6;
+    vec2 off = flow * cycle / 1.6;
+    float blend = abs(t * 2.0 - 1.0);
+    float scale = smoothstep(6.0, 1.0, footprint);
+    for (int k = 0; k < 2; k++) {
+      float tk = fract(t + float(k) * 0.5);
+      vec2 at = q - off * tk + float(k) * 7.3;
+      float e = 0.05;
+      float n0 = wNoise(at);
+      vec2 g = vec2(wNoise(at + vec2(e, 0.0)) - n0, wNoise(at + vec2(0.0, e)) - n0) / e;
+      float wk = k == 0 ? blend : 1.0 - blend;
+      grad += g * 0.06 * wk * scale * min(1.0, speed);
+    }
+  }
+
+  vec3 n = normalize(up - deepward * grad.x - spinward * grad.y);
+  vec3 view = normalize(vWorld - uCam);
+  float cosV = max(0.0, dot(-view, n));
+  // Schlick, with water's 2% at normal incidence.
+  float fresnel = 0.02 + 0.98 * pow(1.0 - cosV, 5.0);
+
+  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
+  float daylight = mix(uBelowFront, uAboveFront, side);
+  float light = uExposure * daylight;
+
+  // Deep water is dark blue-green; what it reflects inside Tower is the far side of the
+  // world, which from here is haze-blue land.
+  vec3 deep = vec3(0.018, 0.045, 0.06);
+  vec3 body = deep * (0.5 + 0.5 * max(0.0, dot(n, up))) * light;
+  // What water mirrors in Tower is land, hazed — never an open sky — so it is not pure haze.
+  vec3 sky = mix(uHaze, vec3(0.30, 0.45, 0.56), 0.35) * light * 0.95;
+  vec3 col = mix(body, sky, fresnel);
+
+  // The beam is a line down the axis: water glints where it mirrors the line back.
+  vec3 refl = reflect(view, n);
+  float toAxis = max(0.0, dot(normalize(vec3(refl.xy, 0.0) + 1e-6), up));
+  // Kept faint: at full strength a rippled river wore the glitter as a white snake.
+  col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light;
+
+  // Foam: rapids and falls, and the edge where the water is only centimetres deep.
+  float depth = vWater.x;
+  float shore = 1.0 - smoothstep(0.0, 0.6, depth);
+  float churn = vFlow.z * (0.55 + 0.45 * wNoise(sp / 0.9 - flow * mod(uTime, 60.0) / 0.9));
+  float foam = clamp(max(churn, shore * 0.6 * step(0.001, vWater.y)), 0.0, 1.0);
+  col = mix(col, vec3(0.85, 0.88, 0.9) * light * (0.34 + 0.8 * dot(n, up)), foam);
+
+  // How much ground shows through: shallow water is clear, a few metres is not.
+  float alpha = clamp(1.0 - exp(-depth / 3.5), 0.2, 0.97);
+  alpha = max(alpha, fresnel);
+  alpha = max(alpha, foam);
+
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
+  #include <colorspace_fragment>
+}
+`
+
+/**
+ * A waterfall's sheet (falls.ts). Streaks run down it at free-fall speed, faster the
+ * further down they are, over a sheet that thins to spray toward its foot.
+ */
+const FALL_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+uniform mat4 uFrame;
+varying vec3 vWorld;
+varying vec2 vUv;
+void main() {
+  vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
+  vUv = uv;
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+const FALL_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform float uTime;
+uniform float uHeightM;
+varying vec3 vWorld;
+varying vec2 vUv;
+${HAZE_GLSL}
+float fHash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
+float fNoise(vec2 p) {
+  vec2 i = floor(p), f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  return mix(mix(fHash(i), fHash(i + vec2(1, 0)), f.x), mix(fHash(i + vec2(0, 1)), fHash(i + vec2(1, 1)), f.x), f.y);
+}
+void main() {
+  #include <logdepthbuf_fragment>
+  // Seconds a streak has been falling to reach this far down: y = g t² / 2. Scrolling in
+  // that rather than in distance makes streaks accelerate down the sheet as water does.
+  float y = vUv.y * uHeightM;
+  float s = sqrt(2.0 * y / 9.80665) - mod(uTime, 30.0);
+  float streak = fNoise(vec2(vUv.x * 18.0, s * 3.0)) * 0.6 + fNoise(vec2(vUv.x * 55.0, s * 9.0)) * 0.4;
+  float edge = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
+  float alpha = (0.55 + 0.45 * streak) * edge * (1.0 - 0.6 * vUv.y);
+  vec3 col = vec3(0.86, 0.9, 0.93) * uExposure * (0.75 + 0.35 * streak);
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
+  #include <colorspace_fragment>
+}
+`
+
 const FLAT_VERT = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_vertex>
@@ -573,6 +804,9 @@ export class InteriorModel {
   /** Every material here, so a caller can set a shared uniform in one loop. */
   readonly materials: ShaderMaterial[] = []
   readonly groundMat: ShaderMaterial
+  readonly waterMat: ShaderMaterial
+  /** Every waterfall in the world, rebuilt when the world is. */
+  readonly falls = new Group()
   /** The generated ground: a quadtree of tiles, refined around the eye. */
   readonly lod: TerrainLOD
   readonly ground: Group
@@ -581,9 +815,62 @@ export class InteriorModel {
 
   constructor(private opts: InteriorModelOptions) {
     this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { side: FrontSide, uniforms: {} })
-    this.lod = new TerrainLOD(terrain(), this.groundMat, opts.lod)
+    /**
+     * Water blends over the ground it covers, and writes no depth: a lake seen through
+     * the shallows of another would otherwise punch holes in it. Both faces, since a wave
+     * near the eye can tip a triangle away.
+     */
+    this.waterMat = this.material(WATER_VERT, WATER_FRAG, { side: DoubleSide, uniforms: {} })
+    this.waterMat.transparent = true
+    this.waterMat.depthWrite = false
+    this.lod = new TerrainLOD(terrain(), this.groundMat, this.waterMat, opts.lod)
     this.ground = this.lod.group
-    this.meshes = [this.ground, ...this.buildCaps(), this.buildBeam()]
+    this.falls.name = 'falls'
+    this.meshes = [this.ground, this.falls, ...this.buildCaps(), this.buildBeam()]
+    terrain().onWorld(() => this.buildFalls())
+    if (terrain().compiled) this.buildFalls()
+  }
+
+  /**
+   * One sheet per fall. They are few — a handful per river — so each is its own mesh and
+   * its own material, which carries the fall's height for the streaks' speed.
+   */
+  private buildFalls(): void {
+    for (const child of [...this.falls.children]) {
+      const mesh = child as Mesh
+      mesh.geometry.dispose()
+      const m = mesh.material as ShaderMaterial
+      this.materials.splice(this.materials.indexOf(m), 1)
+      m.dispose()
+      this.falls.remove(mesh)
+    }
+    for (const f of terrain().compiled?.falls ?? []) {
+      const sheet = fallSheet(f)
+      const g = new BufferGeometry()
+      g.setAttribute('position', new BufferAttribute(sheet.positions, 3))
+      g.setAttribute('uv', new BufferAttribute(sheet.uvs, 2))
+      g.setIndex(new BufferAttribute(sheet.indices, 1))
+      g.computeBoundingSphere()
+      const m = this.material(FALL_VERT, FALL_FRAG, {
+        side: DoubleSide, uniforms: { uHeightM: { value: sheet.heightM } },
+      })
+      m.transparent = true
+      m.depthWrite = false
+      // Copy the shared uniforms from the ground, so a sheet built mid-session starts in
+      // the light of the moment rather than at the defaults.
+      const donor = this.groundMat.uniforms
+      for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime', 'uFrontZ', 'uBelowFront', 'uAboveFront']) {
+        m.uniforms[key]!.value = donor[key]!.value
+      }
+      ;(m.uniforms['uHaze']!.value as Color).copy(donor['uHaze']!.value as Color)
+      ;(m.uniforms['uCam']!.value as Vector3).copy(donor['uCam']!.value as Vector3)
+      ;(m.uniforms['uFrame']!.value as Matrix4).copy(donor['uFrame']!.value as Matrix4)
+      const mesh = new Mesh(g, m)
+      mesh.name = `fall ${f.river}`
+      mesh.position.set(...sheet.origin)
+      mesh.renderOrder = 2
+      this.falls.add(mesh)
+    }
   }
 
   /**
@@ -626,6 +913,8 @@ export class InteriorModel {
       // Only the glazed caps read these, but every material carries them so the
       // per-frame update can walk one list instead of knowing who wants what.
       uSpin: { value: 0 },
+      // Seconds, for anything that moves: the water.
+      uTime: { value: 0 },
       uStarGain: { value: 0 },
       // Only the ground declares these; carried here so one loop can set them.
       uFrontZ: { value: 0 },
@@ -695,6 +984,13 @@ export class InteriorModel {
     })
     const beam = new Mesh(g, m)
     beam.name = 'beam'
+    /**
+     * After the water (1) and the falls (2). Three draws lower render orders first, and the
+     * beam is always in front of any water — water lies on the floor, the beam down the
+     * axis — so drawn earlier, the far side's seas were blended over it and it went fuzzy
+     * wherever it crossed them.
+     */
+    beam.renderOrder = 3
     return beam
   }
 }
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 636bbd5..9453c8d 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -194,6 +194,8 @@ export class InteriorView {
   pitch = 0
   fovDeg = 60
   lighting: Lighting = 'temperate'
+  /** Seconds this view has run, for the water. */
+  private time = 0
   /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
   private spin = 0
   /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */
@@ -998,7 +1000,11 @@ export class InteriorView {
         this.cb.onLook()
       }
       this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
-      for (const m of this.shared) m.uniforms['uSpin']!.value = this.spin
+      this.time += dt
+      for (const m of this.shared) {
+        m.uniforms['uSpin']!.value = this.spin
+        m.uniforms['uTime']!.value = this.time
+      }
       this.fly(dt)
       // Also what refines the ground around the eye, so it runs whether or not the
       // observer moved: tiles keep arriving while they stand still.
diff --git a/src/views/terrain-lod.ts b/src/views/terrain-lod.ts
index e9f3a57..7332643 100644
--- a/src/views/terrain-lod.ts
+++ b/src/views/terrain-lod.ts
@@ -17,7 +17,7 @@ import {
 } from 'three'
 import type { TerrainService } from '../terrain/service.js'
 import {
-  MATERIALS, MAX_LEVEL, ROOTS_ALONG, ROOTS_AROUND, type TileData, type TileGrid, type TileKey,
+  MATERIALS, MAX_LEVEL, ROOTS_ALONG, ROOTS_AROUND, type TileData, type TileGrid, type TileKey, WATER_ATTRS,
   tileCentreKm, tileIndices, tileKey, tileSize,
 } from '../terrain/tile.js'
 
@@ -74,6 +74,7 @@ export class TerrainLOD {
   constructor(
     private service: TerrainService,
     private material: ShaderMaterial,
+    private waterMaterial: ShaderMaterial,
     private opts: TerrainLODOptions,
   ) {
     this.group.name = 'ground'
@@ -259,6 +260,28 @@ export class TerrainLOD {
     mesh.matrixAutoUpdate = false
     mesh.updateMatrix()
     mesh.visible = false
+    if (data.water) mesh.add(this.buildWater(data.water))
+    return mesh
+  }
+
+  /** The tile's water, a child of its ground so it shows and hides and disposes with it. */
+  private buildWater(w: NonNullable<TileData['water']>): Mesh {
+    const g = new BufferGeometry()
+    g.setAttribute('position', new BufferAttribute(w.positions, 3))
+    const attrs = new InterleavedBuffer(w.attrs, WATER_ATTRS)
+    g.setAttribute('aWater', new InterleavedBufferAttribute(attrs, 4, 0))
+    g.setAttribute('aFlow', new InterleavedBufferAttribute(attrs, 4, 4))
+    g.setIndex(new BufferAttribute(w.indices, 1))
+    g.computeBoundingSphere()
+    const release = function (this: { array: unknown }): void { this.array = null }
+    ;(g.attributes['position'] as BufferAttribute).onUpload(release)
+    ;(g.index as BufferAttribute).onUpload(release)
+    attrs.onUpload(release)
+    const mesh = new Mesh(g, this.waterMaterial)
+    mesh.name = 'water'
+    // After the ground, so it blends over the bed.
+    mesh.renderOrder = 1
+    mesh.matrixAutoUpdate = false
     return mesh
   }
 
@@ -279,6 +302,7 @@ export class TerrainLOD {
   private dispose(n: Node): void {
     if (!n.mesh) return
     this.group.remove(n.mesh)
+    for (const child of n.mesh.children) (child as Mesh).geometry.dispose()
     n.mesh.geometry.dispose()
     n.mesh = null
   }
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 3996972..46f69e3 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -101,6 +101,13 @@ export interface Mountains extends Base<'mountains'> {
   snowlineM: number
 }
 
+/**
+ * The wind over a body of water. Direction is where it blows *toward*, in degrees from
+ * deepward toward spinward — the interior view's heading. Phase 4's weather plan
+ * replaces this with Tower's schedule; until then it is the body's steady wind.
+ */
+export interface Wind { directionDeg: number; speedMS: number }
+
 export interface Lake extends Base<'lake'> {
   /** 'auto' fills the basin from its floor, the way a tarn fills (D-020). */
   levelM: number | 'auto'
@@ -111,12 +118,14 @@ export interface Lake extends Base<'lake'> {
   shoreM: number
   /** Height of the moraine around a bowl. */
   rimM: number
+  wind: Wind
 }
 
 export interface Sea extends Base<'sea'> {
   levelM: number
   depthM: number
   shoreM: number
+  wind: Wind
 }
 
 export interface River extends Base<'river'> {
@@ -228,9 +237,11 @@ export function withKind(common: Common, kind: FeatureKind): Feature {
         snowlineM: 3000,
       }
     case 'lake':
-      return { ...c, kind, levelM: 'auto', depthM: 60, basin: 'bowl', shoreM: 2000, rimM: 20 }
+      return { ...c, kind, levelM: 'auto', depthM: 60, basin: 'bowl', shoreM: 2000, rimM: 20,
+        wind: { directionDeg: 60, speedMS: 6 } }
     case 'sea':
-      return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000 }
+      // A ring sea's wind blows round the world: unlimited fetch (world-generation.md §4.4).
+      return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000, wind: { directionDeg: 90, speedMS: 9 } }
     case 'river':
       return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' }
     case 'desert':
@@ -357,11 +368,13 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
         depthM: Math.max(1, num(f['depthM'], d.depthM)),
         basin: f['basin'] === 'flooded' ? 'flooded' : 'bowl',
         shoreM: Math.max(1, num(f['shoreM'], d.shoreM)),
-        rimM: Math.max(0, num(f['rimM'], d.rimM)) }
+        rimM: Math.max(0, num(f['rimM'], d.rimM)),
+        wind: parseWind(f['wind'], d.wind) }
     case 'sea':
       return { ...d, levelM: num(f['levelM'], d.levelM),
                depthM: Math.max(1, num(f['depthM'], d.depthM)),
-               shoreM: Math.max(1, num(f['shoreM'], d.shoreM)) }
+               shoreM: Math.max(1, num(f['shoreM'], d.shoreM)),
+               wind: parseWind(f['wind'], d.wind) }
     case 'river':
       return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)),
                sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain',
@@ -383,6 +396,15 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
   }
 }
 
+function parseWind(w: unknown, fallback: Wind): Wind {
+  const src = w as Partial<Wind> | null
+  if (!src || typeof src !== 'object') return fallback
+  return {
+    directionDeg: num(src.directionDeg, fallback.directionDeg),
+    speedMS: Math.min(60, Math.max(0, num(src.speedMS, fallback.speedMS))),
+  }
+}
+
 function parseGeometry(g: unknown): Geometry | null {
   const src = g as {
     type?: string; theta?: number; z?: number; points?: unknown
diff --git a/test/terrain.test.ts b/test/terrain.test.ts
index 87eb2f4..f71ccd3 100644
--- a/test/terrain.test.ts
+++ b/test/terrain.test.ts
@@ -4,6 +4,7 @@ import { RADIUS_M } from '../src/canon/constants.js'
 import { compileWorld } from '../src/terrain/compile.js'
 import { Stage, prepare } from '../src/terrain/field.js'
 import { nearestA } from '../src/terrain/shapes.js'
+import { buildTile, tileSize } from '../src/terrain/tile.js'
 import { parseWorld, type Mountains, type River } from '../src/world/schema.js'
 
 const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
@@ -208,7 +209,8 @@ describe('water', () => {
 
   it('shapes the floor to the river: never water standing above its own banks', () => {
     for (const r of cw.rivers) {
-      for (let i = 0; i < r.level.length; i += 10) {
+      // Every tenth point, and never the last: it has no next point to be across from.
+      for (let i = 0; i < r.level.length - 1; i += 10) {
         const z = r.line.pts[2 * i]!, theta = r.line.pts[2 * i + 1]! / RADIUS_M
         const s = rt.sample(theta, z, 5)
         // On the centre line the ground is the channel bed, below the water.
@@ -272,6 +274,52 @@ describe('water', () => {
   })
 })
 
+describe('water as geometry', () => {
+  /** The tile at `level` containing (theta, z). */
+  const tileAt = (level: number, theta: number, z: number) => {
+    const size = tileSize(level)
+    const a = ((theta * RADIUS_M) % (2 * Math.PI * RADIUS_M) + 2 * Math.PI * RADIUS_M) % (2 * Math.PI * RADIUS_M)
+    return { level, i: Math.floor(z / size.along), j: Math.floor(a / size.around) }
+  }
+
+  it('gives a tile over a lake a water surface at the lake\'s level', () => {
+    const t = buildTile(rt, tileAt(2, 0.38, 320_000), 33)
+    expect(t.water).not.toBeNull()
+    expect(t.water!.indices.length).toBeGreaterThan(0)
+  })
+
+  it('draws no water on a tile of dry desert', () => {
+    expect(buildTile(rt, tileAt(3, 2.5, 190_000), 33).water).toBeNull()
+  })
+
+  it('draws a river as a surface only once the tile is fine enough to hold it', () => {
+    const trunk = cw.rivers.find((r) => r.id === 'the-long-water')!
+    const i = Math.floor(trunk.level.length * 0.75)
+    const z = trunk.line.pts[2 * i]!, theta = trunk.line.pts[2 * i + 1]! / RADIUS_M
+    // 60 m across: a level-9 tile has a vertex a metre apart; a level-2 tile, 130 m.
+    expect(buildTile(rt, tileAt(9, theta, z), 65).water).not.toBeNull()
+    expect(buildTile(rt, tileAt(2, theta, z), 65).water).toBeNull()
+  })
+
+  it('starts a river at its lake\'s water, not across the lake floor or short of it', () => {
+    const trunk = cw.rivers.find((r) => r.id === 'the-long-water')!
+    const noRivers = prepare({ ...cw, rivers: [] })
+    const wet = (i: number) => noRivers.sample(trunk.line.pts[2 * i + 1]! / RADIUS_M, trunk.line.pts[2 * i]!, 20, Stage.Basins).waterMesh
+    // Its first point is in the tarn's water; its second is already out of it.
+    expect(wet(0)).toBe(true)
+    expect(wet(1)).toBe(false)
+    const tarn = cw.basins.find((b) => b.id === 'hollow-tarn')!
+    expect(trunk.level[0]).toBeCloseTo(tarn.levelM, 3)
+  })
+
+  it('makes every waterfall a single drop, not a staircase', () => {
+    for (const r of cw.rivers) {
+      for (let k = 1; k < r.fallAt.length; k++) expect(r.fallAt[k]! - r.fallAt[k - 1]!).toBeGreaterThan(1)
+    }
+    expect(cw.falls.some((f) => f.topM - f.bottomM > 300)).toBe(true)
+  })
+})
+
 describe('cost', () => {
   it('samples fast enough to build a tile in well under a frame budget per worker', () => {
     const n = 20_000
diff --git a/test/water.test.ts b/test/water.test.ts
new file mode 100644
index 0000000..d4f43c1
--- /dev/null
+++ b/test/water.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+import { freeFallDriftM } from '../src/canon/motion.js'
+import { fallPoint } from '../src/terrain/falls.js'
+import { fetchOf, foamOf, manningSpeed, windSea } from '../src/terrain/water.js'
+
+/** The figures world-generation.md §4.2 and §4.4 print, so the doc and the code agree. */
+describe('wind waves', () => {
+  it('a 10 m/s wind across 30 km of lake raises 0.9 m waves at a 4.2 s period', () => {
+    const s = windSea(10, 30_000)
+    expect(s.heightM).toBeCloseTo(0.9, 1)
+    expect(s.periodS).toBeCloseTo(4.2, 1)
+  })
+
+  it('a mountain tarn 2 km across raises 0.2 m', () => {
+    expect(windSea(10, 2000).heightM).toBeCloseTo(0.23, 2)
+  })
+
+  it('a ring sea, with unlimited fetch, is fully developed: 2.1 m at 10 m/s, 8.6 m at 20', () => {
+    expect(windSea(10, Infinity).heightM).toBeCloseTo(2.14, 2)
+    expect(windSea(20, Infinity).heightM).toBeCloseTo(8.57, 2)
+  })
+
+  it('never builds a sea past fully developed, however far the wind blows', () => {
+    expect(windSea(15, 5e6).heightM).toBeLessThanOrEqual(windSea(15, Infinity).heightM + 1e-9)
+  })
+
+  it('gives deep-water wavelength for the peak period', () => {
+    const s = windSea(10, 30_000)
+    expect(s.lengthM).toBeCloseTo((9.80665 * s.periodS ** 2) / (2 * Math.PI), 6)
+  })
+
+  it('makes no waves without wind', () => {
+    expect(windSea(0, 1e5).heightM).toBe(0)
+  })
+})
+
+describe('fetch', () => {
+  const band = { type: 'band', zFrom: 0, zTo: 42_000 } as const
+  it('is unlimited around a ring sea', () => {
+    expect(fetchOf(band, Math.PI / 2)).toBe(Infinity)
+  })
+  it('is the width of the band for a wind straight along the axis', () => {
+    expect(fetchOf(band, 0)).toBeCloseTo(42_000, 6)
+  })
+})
+
+describe('rivers', () => {
+  it('runs a lowland river at a walking pace and a mountain one fast', () => {
+    expect(manningSpeed(2.7, 0.002)).toBeGreaterThan(1)
+    expect(manningSpeed(2.7, 0.002)).toBeLessThan(3)
+    expect(manningSpeed(1.5, 0.08)).toBeGreaterThan(manningSpeed(1.5, 0.002))
+  })
+  it('turns white as it steepens', () => {
+    expect(foamOf(0.002)).toBe(0)
+    expect(foamOf(0.2)).toBe(1)
+  })
+})
+
+describe('waterfalls', () => {
+  const fall = { river: 'r', z: 100_000, a: 5000, dz: 1, da: 0, topM: 1700, bottomM: 700, widthM: 60, speedMS: 3 }
+
+  it('lands antispinward of the lip by the free-fall drift: 133 m for a kilometre', () => {
+    const lip = fallPoint(fall, 0, 0)
+    const foot = fallPoint(fall, 0, 1000)
+    // A river running deepward: spinward is +a, so the drift is a loss of a.
+    expect(lip.a - foot.a).toBeCloseTo(freeFallDriftM(1000), 6)
+    expect(lip.a - foot.a).toBeCloseTo(133, -1)
+  })
+
+  it('carries the river forward over the lip at its own speed', () => {
+    const t = Math.sqrt((2 * 1000) / 9.80665)
+    expect(fallPoint(fall, 0, 1000).z - fall.z).toBeCloseTo(3 * t, 6)
+  })
+
+  it('leans the same way whichever way the river runs', () => {
+    const spinwardRiver = { ...fall, dz: 0, da: 1 }
+    const foot = fallPoint(spinwardRiver, 0, 500)
+    const noSpin = spinwardRiver.a + spinwardRiver.da * spinwardRiver.speedMS * Math.sqrt((2 * 500) / 9.80665)
+    expect(noSpin - foot.a).toBeCloseTo(freeFallDriftM(500), 6)
+  })
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index e1e612f..7037ddc 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -291,6 +291,29 @@ const SHOTS = [
       await settle(page, 1400)
     },
   },
+  {
+    id: 'interior-falls',
+    caption: 'Interior — the Long Water falling 680 m off the range into its gorge, leaning antispinward as it drops',
+    on: ['desktop'],
+    run: async (page) => {
+      await openView(page, 'interior')
+      await pick(page, 'The Long Water')
+      await settle(page, 300)
+      // Downstream of the world's tallest fall, looking back up the gorge at it.
+      await page.evaluate(() => {
+        const tw = window.tower
+        const falls = window.towerTerrain.compiled.falls
+        const f = falls.reduce((a, b) => (b.topM - b.bottomM > a.topM - a.bottomM ? b : a))
+        const z = f.z + f.dz * 1500, a = f.a + f.da * 1500
+        tw.visit({ theta: a / 50000, z })
+        tw.yaw = (Math.atan2(f.a - a, f.z - z) + 2 * Math.PI) % (2 * Math.PI)
+        tw.pitch = 0.3
+        tw.fovDeg = 55
+        tw.setEyeHeight(30)
+      })
+      await settle(page, 1400)
+    },
+  },
   {
     id: 'interior-overhead',
     caption: 'Interior — looking up: the far side 100 km overhead is clearer than ground 100 km away',
@@ -391,7 +414,16 @@ const SHOTS = [
 ]
 
 /** Reader mode is a query parameter, so it has to be chosen before the page loads. */
-const searchFor = (shot) => (shot.id === 'reader' ? '?reader=1' : '')
+/**
+ * Every page asks for two terrain workers and the compiled-world cache. Several pages run
+ * at once, and each opening a full pool of six and compiling the whole world — erosion
+ * and all — was a laptop running hot for a minute. With the cache warmed once (below),
+ * no shot compiles at all.
+ */
+const TOUR_PARAMS = 'workers=2&cache=1'
+const searchFor = (shot) => (shot.id === 'reader' ? `?reader=1&${TOUR_PARAMS}` : `?${TOUR_PARAMS}`)
+/** The storage every shot's fresh context starts from: the warm cache, and nothing else. */
+let warmStorage = undefined
 
 const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`)
 
@@ -430,6 +462,7 @@ async function capture(browser, base, profile, shot) {
    * previous one's camera and quietly stop being reproducible.
    */
   const context = await browser.newContext({
+    storageState: warmStorage,
     viewport: profile.viewport,
     deviceScaleFactor: profile.scale,
     isMobile: profile.mobile,
@@ -457,6 +490,10 @@ async function capture(browser, base, profile, shot) {
   await settle(page)
   await shot.run(page)
 
+  // A shot that compiled the world itself means the warm cache did not reach it.
+  const compiled = await page.evaluate(() => window.towerTerrain && !window.towerTerrain.lastFromCache)
+  if (compiled) problems.push('compiled the world itself: the warm cache was missed')
+
   const file = fileFor(profile.id, shot.id)
   await page.screenshot({ path: file, scale: 'css' })
   await context.close()
@@ -622,6 +659,25 @@ try {
      * matters here more than speed does, since a shot that settles differently
      * between runs shows up as divergence rather than as a slow run.
      */
+    /**
+     * Compile the world once, in a page of its own, and hand every shot the IndexedDB it
+     * leaves behind. localStorage is emptied first: it holds the view and the camera, and
+     * a shot inheriting those would stop being reproducible.
+     */
+    {
+      const t0 = Date.now()
+      const context = await tour.newContext()
+      const page = await context.newPage()
+      await page.goto(`${base}/?${TOUR_PARAMS}`, { waitUntil: 'networkidle' })
+      await page.waitForFunction(() => window.towerTerrain && !window.towerTerrain.progress.busy
+        && window.towerTerrain.compiled, null, { timeout: 120_000, polling: 200 })
+      // saveCompiled is not awaited by the app; give its transaction a moment to land.
+      await page.waitForTimeout(500)
+      await page.evaluate(() => localStorage.clear())
+      warmStorage = await context.storageState({ indexedDB: true })
+      await context.close()
+      console.log(`  world compiled once for the tour  ${((Date.now() - t0) / 1000).toFixed(1)}s`)
+    }
     const jobs = []
     for (const profile of PROFILES) {
       for (const shot of SHOTS) {
diff --git a/world-generation.md b/world-generation.md
index 23bec88..2ba6bce 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1,7 +1,7 @@
 # Tower — Live World Generation
 
-**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1 and 2 are built**
-(D-063, D-064, D-065); phases 3–8 are not. This document replaces the terrain pipeline of D-019 and reverses
+**Status: IN PROGRESS.** Accepted by the author (D-062). **Phases 1–3 are built**
+(D-063 to D-066); phases 4–8 are not. This document replaces the terrain pipeline of D-019 and reverses
 D-006; see §11 for every existing decision it touches, and §15 for where Phase 1
 departed from the plan and why.
 
@@ -1018,7 +1018,7 @@ screenshot check before any commit.
 |---|---|---|
 | 1 ✅ | **Foundation** | schema v2 + port script; status dialog; elevation function with ground + today's landforms reimplemented; worker pool; quadtree tile renderer on the cylinder; surface splatting; ground query; keyboard flight; map preview from the coarse field. Delete `tools/terrain.mjs`, the PNGs, `raster.ts`. **Parity checkpoint:** today's world, without the bitmap |
 | 2 ✅ | **Mountains** | ridge graph, erosion, peak editing |
-| 3 | **Water** | seas and lakes with surfaces and waves; rivers with profiles, flow and waterfalls |
+| 3 ✅ | **Water** | seas and lakes with surfaces and waves; rivers with profiles, flow and waterfalls |
 | 4 | **Weather and ships** | weather plan with revisions and the day clock; plan board in the panel; procedural hulls, routes, harbour waits, heave on the waves |
 | 5 | **Desert** | dunes, hills, terraced plateaus, washes, salt pans, shorelines |
 | 6 | **Forest** | density field, stands, meadows, the tall forest, three-distance tree rendering |
@@ -1115,3 +1115,45 @@ desert's ragged edge; standing at The Narrows and looking up from Greatwood matc
 within a few metres of elevation. Cold load of the production build to a fully detailed
 interior is 4.3 s on the author's laptop; a compile runs in about 120 ms and the map
 draws in about 1.2 s.
+
+## 17. Phase 3, as built (D-066)
+
+- **Water is a mesh per tile**, on the tile's own grid: every wet vertex carries its
+  water's level, a dry vertex beside one borrows it, and the depth test draws the shore.
+  The ground under drawn water is bed. A river narrower than a tile's vertex spacing is
+  not drawn as a surface there, only as a tint on the ground in proportion to how much of
+  each cell it covers — a coarse tile's vertex landing in a channel drew a 500 m blue
+  diamond, and a line of them down every river.
+- **Waves**: `water.ts` gives JONSWAP fetch-limited seas capped at Pierson–Moskowitz, and
+  the tests pin §4.2 and §4.4's printed figures. Lakes and seas have a `wind`; the editor
+  shows the sea it raises. Phase 4's weather plan replaces the steady wind.
+- **Rivers**: speed by Manning, foam by grade. Ripples are advected downstream in the
+  shader by a two-phase flow map.
+- **Waterfalls come from the profile.** The least-earthwork fit pools reaches and steps
+  between pools, so a step of 12 m or more is a fall. A river coming off a range stepped
+  every 200 m for kilometres; consecutive steps are merged into one drop, with the reach
+  below taking the foot's level — which cuts a **gorge**. The valley's walls are shaped
+  from a separate *wall level* that ramps smoothly through the drop, so the gorge is a slot
+  inside an unbroken valley; embankments use the same ramp, or they ended in a cliff the
+  width of the embankment. Gorge walls stand at about 50°, wander with noise, and share the
+  valley's curvature, or they cut a second cliff kilometres out.
+- **Sheets lean antispinward** by `canon/motion.ts`'s free-fall drift, tested.
+- **Skirts reach past a tile's steepest step.** On a gorge wall a coarser neighbour's edge
+  can be off by far more than 4% of the tile, and the cracks showed as dark panels.
+- **Rivers start and end at the water's edge.** v1 drew the Long Water from the centre of
+  Hollow Tarn, and kept whole its channel was cut across the lake floor, its water a stripe
+  across the tarn (reported by the author). A river is now trimmed where it meets a lake —
+  found by sampling the water, since a tarn's water fills only about the inner 70% of its
+  outline, and a trim to the outline left the river stopping short of it.
+- **Narrow rivers stay visible at a distance.** Where a tile is too coarse to draw a river
+  as a surface it tints the ground by the square root of the share of each cell the river
+  covers: by the plain share, a 40 m river in a 200 m cell was a 20% tint, and vanished
+  where tiles coarsened.
+- **The beam is drawn after water**, or the far side's seas blended over it and it went
+  fuzzy where it crossed them. Reported by the author.
+- **The compiled world is cached** in IndexedDB (§5.4), keyed by the world's terrain
+  signature and the worker bundle's URL — hashed in a build, so a new build never reads an
+  old world. The screenshot tour compiles once and gives every shot the warm cache, and
+  caps each page at two workers: it had been compiling the world fifteen times, four at a
+  time, with a full pool each, and the author's laptop ran hot for it.
+- **Cost**: 99–112 fps with water in view, 2.1–2.6 M triangles, on the author's laptop.