fuzzyworld

nordic-skating / commit

feat(lake): forks round islands, narrow passes, and stretches of open sea

1cb1fefce2587ab297b9886070b606795393e7e8 · Pavel Safronov · 2026-10-09 08:29:34 -0700 · parents 646446cd7898

Claude Code usage: this commit ~151.4K out / ~13.6M cached · cost unknown (no per-model trailer) · session 1 tally 470 in / 402.0K out / 30.6M cached · usage by commit and session

feat(lake): forks round islands, narrow passes, and stretches of open sea

The lake is now channels that fork. A channel runs from the fork that
made it to the fork that ends it; a fork's two channels start as the one
and draw 180 m apart over 600 m, always more than both are wide, so
there is always an island. The first fork is 700 m in and each later one
1.8 to 4.2 km after the last. Both channels are drawn for 1.2 km and
then only the one taken.

Until the island begins the skater is in whichever channel they are
nearer the middle of, so steering across takes the other and doing
nothing takes the one the fork prefers.

The lake changes character over kilometres: about 70% of the way it is
ordinary water, about 10% a pass 5 m to a side, and now and then it
opens until both shores are beyond the haze. Near a fork it is always
ordinary. Where it narrows faster than a skater could steer clear, their
room closes 200 m early; at sea they keep within 150 m of the middle.

The map of thickness painted on the ice follows the skater and is wide
enough for the sea.

Usage: 470 in / 402.0K out / 30.6M cached (session tally)
rtk:   1.8M saved, 25.6% over 2830 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                 |  60 ++++++++++-----
 docs/screenshots/fork.png | Bin 0 -> 321595 bytes
 docs/screenshots/pass.png | Bin 0 -> 241573 bytes
 docs/screenshots/sea.png  | Bin 0 -> 209619 bytes
 src/main.ts               |  12 +--
 src/render/ice.ts         |  30 +++++---
 src/render/shores.ts      | 150 +++++++++++++++++++++++++++-----------
 src/skater.ts             |  90 +++++++++++++++++++----
 src/world/lake.ts         | 182 +++++++++++++++++++++++++++++++++++++++-------
 test/lake.test.ts         | 148 ++++++++++++++++++++++++++++---------
 test/skater.test.ts       |  95 ++++++++++++++++--------
 tools/screenshots.mjs     |  12 ++-
 12 files changed, 589 insertions(+), 190 deletions(-)

diff --git a/README.md b/README.md
index 9874422..926e862 100644
--- a/README.md
+++ b/README.md
@@ -15,15 +15,17 @@ in which Mårten Ajne skates a small lake outside Stockholm.
 
 ## Status
 
-**Step 4 of 6: thin ice.** The lake runs on for ever between snowy, wooded shores, the
-skater skates it, and the ice sings under every stride at a pitch set by how thick it is
-there. Thin ice is black, sings high, cracks under you, and at its thinnest gives way.
+**Step 5 of 6: forks, passes and open sea.** The lake runs on for ever between snowy,
+wooded shores, forking every few kilometres, pinching to narrow passes and opening out
+until no shore can be seen. The skater skates it, and the ice sings under every stride at
+a pitch set by how thick it is there. Thin ice is black, sings high, cracks under you,
+and at its thinnest gives way.
 
 1. ✅ Scaffold: build, tests, deploy to GitLab Pages.
 2. ✅ An endless lake, skated on autopilot, with steering.
 3. ✅ Measure the reference video, then synthesize the ice sound from the ice's thickness.
 4. ✅ Ice zones that fall out of the physics, and falling through.
-5. Forks in the lake.
+5. ✅ Forks in the lake, narrow passes, and stretches of open sea.
 6. Game controller, then multiplayer.
 
 ## Controls
@@ -31,16 +33,21 @@ there. Thin ice is black, sings high, cracks under you, and at its thinnest give
 Tap or press a key once to turn the sound on: a browser lets a page make sound only
 after it has been touched. On an iPhone the ring/silent switch also silences it.
 
-Do nothing and the skater follows the middle of the lake, round every bend, for as
-long as you leave it.
+Do nothing and the skater follows the middle of the lake, round every bend and down
+one side of every fork, for as long as you leave it.
 
 | To | Touch or mouse | Keyboard |
 |---|---|---|
 | Steer left or right | Drag sideways from wherever you touch | ← → or A D |
+| Choose a way at a fork | Steer towards it before the island begins | the same |
 
 Steering moves the skater across the lake, never into a shore. Let go and the skater
 holds that line for a second and a half, then eases back to the middle.
 
+At a fork the lake parts round an island. Until the island begins, a little over 200 m
+after the fork, you are in whichever of the two channels you are nearer the middle of,
+so steering across takes the other one. After that there is land between.
+
 ## The ice
 
 How thick the ice is decides everything about it: how it looks, the note it sings, and
@@ -55,24 +62,43 @@ formula for what a sheet of ice will carry (`src/ice/strength.ts`), worked out f
 | Sings | 3.4 to 4.8 cm | dark grey | 630 to 900 Hz | Sound ice near its limit. The ice of the film is here, at 4.1 cm |
 | Safe | over 4.8 cm | milky grey | below 630 Hz | Nothing. The thicker, the lower and plainer the note |
 
-Of the ice a skater can reach, about 3% gives way, 21% cracks, 41% sings and 36% is
-safe. The middle of the lake is never thinner than 4.1 cm, so the autopilot never falls
-through: where a thin patch crosses the middle, a strip of sound ice bridges it.
+Of the ice a skater can reach, about 2% gives way, 18% cracks, 45% sings and 35% is
+safe. The middle of every channel is never thinner than 4.1 cm, so the autopilot never
+falls through: where a thin patch crosses the middle, a strip of sound ice bridges it.
 
-<img src="docs/screenshots/thin-ice.png" alt="Milky grey ice down the lake, with a black patch of thin ice to the right of the middle" width="640" />
+<img src="docs/screenshots/thin-ice.png" alt="Milky grey ice down the lake, with a black patch of thin ice to the left of the middle" width="640" />
 
 ## How the lake works
 
-- **The lake is a function, not a map** (`src/world/lake.ts`). Where its middle is, how
-  wide it is and how the land rises from its shores are all computed from the distance
-  skated and a seed. Any stretch can be made on demand, the same on every device, and
-  nothing is stored.
-- **The skater is an offset from the middle of the lake** (`src/skater.ts`), not a
+- **The lake is a function, not a map** (`src/world/lake.ts`). Where a channel's middle
+  is, how wide it is and how the land rises from its shores are all computed from the
+  distance skated and a seed. Any stretch can be made on demand, the same on every
+  device, and nothing is stored.
+- **It forks.** A channel runs from the fork that made it to the fork that ends it: the
+  first fork is 700 m from the start, and each one after comes 1.8 to 4.2 km after the
+  last. A fork's two channels start as the one channel and draw 180 m apart over 600 m,
+  which is always more than both are wide, so there is always an island. Each has a
+  width and forks of its own from there on. Both are drawn for 1.2 km, and then only the
+  one taken: the other is out of sight behind the island by then.
+- **It changes character over kilometres.** About 70% of the way it is ordinary water,
+  12 to 44 m from the middle to a shore. About 10% of the way it pinches to a pass 5 m
+  to a side. And now and then it opens until both shores are beyond the haze, 700 m off
+  or more: a stretch of sea. Near a fork it is always ordinary, so that a fork reads as
+  one.
+
+<img src="docs/screenshots/fork.png" alt="The lake parting round a wooded island ahead" width="640" />
+
+<img src="docs/screenshots/sea.png" alt="Ice to a hazy horizon with no shore in sight" width="640" />
+
+- **The skater is an offset from the middle of a channel** (`src/skater.ts`), not a
   position. An offset nobody touches follows every bend, which is all the autopilot is.
+  Where the lake narrows faster than a skater could steer clear, the room they have
+  starts closing 200 m early, so a shore never sweeps them sideways. Out at sea they may
+  go no further than 150 m from the middle.
 - **Thickness is a function too**, and is painted on the ice from a small map of the 768 m
   around the skater that is redrawn every 32 m (`src/render/ice.ts`).
-- **The skater never leaves the origin of what is drawn.** The shores are built 100 m at
-  a time ahead of the skater, thrown away behind, and placed relative to the skater
+- **The skater never leaves the origin of what is drawn.** The shores and islands are
+  built 100 m at a time ahead of the skater, thrown away behind, and placed relative to the skater
   every frame (`src/render/shores.ts`); the ice is one sheet whose texture slides the
   other way (`src/render/ice.ts`). So nothing drawn is ever a large number, and the
   picture is as sharp 10,000 km down the lake as at the start.
diff --git a/docs/screenshots/fork.png b/docs/screenshots/fork.png
new file mode 100644
index 0000000..4441a43
Binary files /dev/null and b/docs/screenshots/fork.png differ
diff --git a/docs/screenshots/pass.png b/docs/screenshots/pass.png
new file mode 100644
index 0000000..5388e6b
Binary files /dev/null and b/docs/screenshots/pass.png differ
diff --git a/docs/screenshots/sea.png b/docs/screenshots/sea.png
new file mode 100644
index 0000000..1fa2677
Binary files /dev/null and b/docs/screenshots/sea.png differ
diff --git a/src/main.ts b/src/main.ts
index 0ebe9f0..0c3a96c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -61,7 +61,7 @@ resize()
 
 // `?s=2500` starts that many metres down the lake: for screenshots, and for checking
 // that nothing degrades a long way from the start.
-const skater = createSkater(Number(new URLSearchParams(location.search).get('s')) || 0)
+const skater = createSkater(lake, Number(new URLSearchParams(location.search).get('s')) || 0)
 const input = new Input(canvas)
 const clock = new THREE.Clock()
 // Whoever is just watching has no use for the hint after a first read of it.
@@ -93,7 +93,7 @@ renderer.setAnimationLoop(() => {
   const strides = Math.floor(skater.stride)
   const wasUnder = skater.under > 0
   stepSkater(skater, lake, steer, dt)
-  const h = lake.thickness(skaterX(skater, lake), skater.s)
+  const h = lake.thickness(skaterX(skater), skater.s, skater.channel)
   const fell = skater.under > 0 && !wasUnder
   if (fell) sound?.fall(h)
   // In the water for all but the last second, which is the climb back out.
@@ -120,17 +120,17 @@ renderer.setAnimationLoop(() => {
   // Each stride pushes off one foot and then the other, so the body sways across the
   // line of travel once every two strides, leans into the sway, and dips at each push.
   const sway = Math.sin(skater.stride * Math.PI)
-  const x = skaterX(skater, lake) + 0.2 * sway
+  const x = skaterX(skater) + 0.2 * sway
   // Going through the ice drops the view to the water in the first third of a second.
   const sunk = skater.under > 0 ? Math.min(1, (UNDER_SECONDS - skater.under) / 0.3) : 0
   camera.position.y = EYE - 0.04 * Math.abs(sway) - 1.3 * sunk
   // The eyes follow the channel, and turn only half way into a steer across it: a skater
   // cutting towards a shore still watches where the lake goes.
-  const along = Math.atan(lake.slope(skater.s))
+  const along = Math.atan(skater.channel.slope(skater.s))
   const look = along + 0.5 * (skater.heading - along)
   camera.rotation.set(-pitch, -look, -0.015 * sway - 0.012 * skater.lateral)
 
-  ice.update(x, skater.s)
-  shores.update(x, skater.s)
+  ice.update(x, skater.s, skater.channel)
+  shores.update(x, skater.s, skater.channel)
   renderer.render(scene, camera)
 })
diff --git a/src/render/ice.ts b/src/render/ice.ts
index 6df8e4a..03ef2f1 100644
--- a/src/render/ice.ts
+++ b/src/render/ice.ts
@@ -1,6 +1,6 @@
 import * as THREE from 'three'
 import { BREAKING_THICKNESS } from '../ice/strength.js'
-import { type Lake, MAX_THICKNESS, mulberry32 } from '../world/lake.js'
+import { type Channel, type Lake, MAX_THICKNESS, mulberry32 } from '../world/lake.js'
 
 // Black ice: clear enough that the dark water under it is what you see.
 const ICE = '#0b1a24'
@@ -15,11 +15,13 @@ const SIZE = 3200
 /**
  * How thick the ice is, is painted on it from a small map of the lake around the skater:
  * this many metres across and along, of which `MAP_BEHIND` are behind the skater, at one
- * value every `MAP_STEP` metres. It is redrawn each time the skater has gone `MAP_REDRAW`.
+ * value every `MAP_STEP_ACROSS` metres across and `MAP_STEP` along. It is redrawn each
+ * time the skater has gone `MAP_REDRAW` metres along, or twice that across.
  */
-const MAP_ACROSS = 512
+const MAP_ACROSS = 1536
 const MAP_ALONG = 768
 const MAP_BEHIND = 96
+const MAP_STEP_ACROSS = 12
 const MAP_STEP = 4
 const MAP_REDRAW = 32
 /** Thick ice is milky with frost and trapped air; this is the colour that adds. */
@@ -121,8 +123,8 @@ export class Ice {
     // clear; thicker ice is milkier. So the map of thickness is laid on as light added to
     // the dark sheet, none where the ice will not hold and more the thicker it gets.
     this.map = new THREE.DataTexture(
-      new Uint8Array((MAP_ACROSS / MAP_STEP) * (MAP_ALONG / MAP_STEP) * 4),
-      MAP_ACROSS / MAP_STEP,
+      new Uint8Array((MAP_ACROSS / MAP_STEP_ACROSS) * (MAP_ALONG / MAP_STEP) * 4),
+      MAP_ACROSS / MAP_STEP_ACROSS,
       MAP_ALONG / MAP_STEP,
     )
     this.map.colorSpace = THREE.SRGBColorSpace
@@ -136,14 +138,17 @@ export class Ice {
     this.mesh.rotation.x = -Math.PI / 2
   }
 
-  /** Put the right patch of ice under a skater who is at `x` across and `s` along. */
-  update(x: number, s: number): void {
+  /**
+   * Put the right patch of ice under a skater who is at `x` across and `s` along, in
+   * `channel`.
+   */
+  update(x: number, s: number, channel: Channel): void {
     // Only the fraction of a tile matters, and taking it here, in doubles, is what keeps
     // the picture sharp at any distance.
     // (The sheet is a whole, even number of tiles across, so its middle is on a tile edge.)
     this.texture.offset.set((x / TILE) % 1, (s / TILE) % 1)
 
-    if (!(s - MAP_BEHIND - this.mapS < MAP_REDRAW)) this.drawMap(s)
+    if (!(s - MAP_BEHIND - this.mapS < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s, channel)
     // The map stays where it was drawn while the sheet moves on with the skater.
     this.map.offset.set(
       (x - this.mapX + MAP_ACROSS / 2 - SIZE / 2) / MAP_ACROSS,
@@ -151,17 +156,18 @@ export class Ice {
     )
   }
 
-  private drawMap(s: number): void {
+  private drawMap(x: number, s: number, channel: Channel): void {
     // On whole steps, so that a redraw lands every value exactly where it was before.
     this.mapS = Math.floor((s - MAP_BEHIND) / MAP_STEP) * MAP_STEP
-    this.mapX = Math.round(this.lake.centre(s + MAP_ALONG / 3) / MAP_STEP) * MAP_STEP
+    this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS
     const data = this.map.image.data as Uint8Array
     let p = 0
     for (let row = 0; row < MAP_ALONG / MAP_STEP; row++) {
-      for (let col = 0; col < MAP_ACROSS / MAP_STEP; col++) {
+      for (let col = 0; col < MAP_ACROSS / MAP_STEP_ACROSS; col++) {
         const h = this.lake.thickness(
-          this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP,
+          this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP_ACROSS,
           this.mapS + (row + 0.5) * MAP_STEP,
+          channel,
         )
         const milk = Math.max(0, (h - BREAKING_THICKNESS) / (MAX_THICKNESS - BREAKING_THICKNESS))
         data[p++] = data[p++] = data[p++] = Math.round(255 * milk)
diff --git a/src/render/shores.ts b/src/render/shores.ts
index 3817a98..234838d 100644
--- a/src/render/shores.ts
+++ b/src/render/shores.ts
@@ -1,17 +1,20 @@
 import * as THREE from 'three'
-import { type Lake, mulberry32 } from '../world/lake.js'
+import { type Channel, type Lake, channelsAt, mulberry32 } from '../world/lake.js'
 
 /** The shores are made a stretch at a time, this many metres of lake each. */
 const CHUNK = 100
 /** Metres between rows of the land's mesh along the lake. */
 const STEP = 5
 const ROWS = CHUNK / STEP
-/** How far inland each column of the mesh is, metres: fine at the shore, coarse far off. */
+/** How far inland each column of a bank's mesh is, metres: fine at the shore, coarse far off. */
 const INLAND = [0, 2, 6, 14, 30, 60, 120, 220, 400]
+/** How far across an island each column of its mesh is, as a fraction of its width. */
+const ACROSS = [0, 0.04, 0.12, 0.25, 0.5, 0.75, 0.88, 0.96, 1]
 /** Stretches kept behind the skater and made ahead, metres. The fog ends at 600. */
 const BEHIND = 100
 const AHEAD = 700
-const TREES_PER_SIDE = 60
+const TREES_PER_BANK = 60
+const TREES_PER_ISLAND = 50
 
 const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 })
 const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true })
@@ -25,36 +28,61 @@ interface Chunk {
   group: THREE.Group
 }
 
+/** The water's edges at `s`: the outer shores, and the island between two channels. */
+interface Shoreline {
+  left: number
+  right: number
+  /** The island's two shores; the same number where there is no island. */
+  islandLeft: number
+  islandRight: number
+}
+
+function shoreline(channel: Channel, s: number): Shoreline {
+  const channels = channelsAt(channel, s)
+  const first = channels[0]!
+  const last = channels[channels.length - 1]!
+  const left = first.centre(s) - first.halfWidth(s)
+  const right = last.centre(s) + last.halfWidth(s)
+  const islandLeft = first.centre(s) + first.halfWidth(s)
+  const islandRight = last.centre(s) - last.halfWidth(s)
+  // One channel, or two that have not yet drawn apart: no island, and its two shores
+  // are put at one point so that the mesh made for it closes up to nothing.
+  if (islandRight <= islandLeft) {
+    const middle = (islandLeft + islandRight) / 2
+    return { left, right, islandLeft: middle, islandRight: middle }
+  }
+  return { left, right, islandLeft, islandRight }
+}
+
 /**
- * One side's land for one stretch, as a mesh whose vertices are measured from the
- * stretch's own origin (`x0`, `s0`) so that they stay small numbers.
+ * A strip of land for one stretch, `columns` wide, as a mesh whose vertices are measured
+ * from the stretch's own origin (`x0`, `s0`) so that they stay small numbers. `place`
+ * says where column `col` is at `s`, across and up.
  */
-function land(lake: Lake, side: -1 | 1, x0: number, s0: number): THREE.BufferGeometry {
-  const cols = INLAND.length
+function land(columns: number, x0: number, s0: number, place: (s: number, col: number) => [number, number]): THREE.BufferGeometry {
   // One row more at each end than is drawn. The normals are worked out with them and
   // they are then left out, so the shading runs unbroken from one stretch into the next.
-  const positions = new Float32Array((ROWS + 3) * cols * 3)
+  const positions = new Float32Array((ROWS + 3) * columns * 3)
   let p = 0
   for (let row = -1; row <= ROWS + 1; row++) {
     const s = s0 + row * STEP
-    const shore = lake.centre(s) + side * lake.halfWidth(s)
-    for (const d of INLAND) {
-      positions[p++] = shore + side * d - x0
-      positions[p++] = lake.bankHeight(side, s, d)
+    for (let col = 0; col < columns; col++) {
+      const [x, y] = place(s, col)
+      positions[p++] = x - x0
+      positions[p++] = y
       positions[p++] = -(s - s0)
     }
   }
   const quads = (from: number, to: number): number[] => {
     const index: number[] = []
     for (let row = from; row < to; row++) {
-      for (let col = 0; col < cols - 1; col++) {
-        const a = row * cols + col
+      for (let col = 0; col < columns - 1; col++) {
+        const a = row * columns + col
         const b = a + 1
-        const c = a + cols
+        const c = a + columns
         const d = c + 1
-        // Wound so that both sides' land faces up.
-        if (side === 1) index.push(a, b, c, b, d, c)
-        else index.push(a, c, b, b, c, d)
+        // Columns run left to right; wound so that the land faces up.
+        index.push(a, b, c, b, d, c)
       }
     }
     return index
@@ -67,35 +95,43 @@ function land(lake: Lake, side: -1 | 1, x0: number, s0: number): THREE.BufferGeo
   return geometry
 }
 
-function trees(lake: Lake, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh {
+function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh {
   const random = mulberry32(seed ^ Math.imul(index, 0x9e3779b1))
-  const mesh = new THREE.InstancedMesh(cone, spruce, TREES_PER_SIDE * 2)
+  const mesh = new THREE.InstancedMesh(cone, spruce, TREES_PER_BANK * 2 + TREES_PER_ISLAND)
   const at = new THREE.Object3D()
   let n = 0
+  const plant = (x: number, s: number, d: number, size: number): void => {
+    at.position.set(x - x0, lake.bankHeight(1, s, d) - 0.3, -(s - s0))
+    at.rotation.y = random() * Math.PI
+    at.scale.set(size, size * (0.8 + random() * 0.5), size)
+    at.updateMatrix()
+    mesh.setMatrixAt(n++, at.matrix)
+  }
   for (const side of [-1, 1] as const) {
-    for (let i = 0; i < TREES_PER_SIDE; i++) {
+    for (let i = 0; i < TREES_PER_BANK; i++) {
       const s = s0 + random() * CHUNK
+      const shore = shoreline(channel, s)
       // Thick along the shore and thinning out up the hill.
       const d = 5 + random() ** 2 * 170
-      const size = 0.7 + random() * 0.9
-      at.position.set(
-        lake.centre(s) + side * (lake.halfWidth(s) + d) - x0,
-        lake.bankHeight(side, s, d) - 0.3,
-        -(s - s0),
-      )
-      at.rotation.y = random() * Math.PI
-      at.scale.set(size, size * (0.8 + random() * 0.5), size)
-      at.updateMatrix()
-      mesh.setMatrixAt(n++, at.matrix)
+      plant((side < 0 ? shore.left : shore.right) + side * d, s, d, 0.7 + random() * 0.9)
     }
   }
+  for (let i = 0; i < TREES_PER_ISLAND; i++) {
+    const s = s0 + random() * CHUNK
+    const { islandLeft, islandRight } = shoreline(channel, s)
+    const width = islandRight - islandLeft
+    const x = islandLeft + 2 + random() * (width - 4)
+    // No island here, or one too narrow to stand a tree on: a tree of no size.
+    plant(x, s, Math.min(x - islandLeft, islandRight - x), width > 8 ? 0.7 + random() * 0.9 : 0)
+  }
   return mesh
 }
 
 /**
- * The land on both sides of the lake, made ahead of the skater and thrown away behind.
- * Each stretch is placed relative to the skater every frame, so the skater is always at
- * the origin of what is drawn and the lake can run on without losing precision.
+ * The land of the lake: both banks, and the island wherever it has forked. It is made
+ * ahead of the skater and thrown away behind, and each stretch is placed relative to the
+ * skater every frame, so the skater is always at the origin of what is drawn and the lake
+ * can run on without losing precision.
  */
 export class Shores {
   readonly group = new THREE.Group()
@@ -103,8 +139,16 @@ export class Shores {
 
   constructor(private readonly lake: Lake, private readonly seed: number) {}
 
-  /** Make, drop and place the stretches for a skater at `x` across and `s` along. */
-  update(x: number, s: number): void {
+  /**
+   * Make, drop and place the stretches for a skater at `x` across and `s` along, who is
+   * in `channel`.
+   *
+   * A stretch is made once, with whichever channel the skater was in at the time, and
+   * that is safe: a stretch near a fork has both of the fork's channels in it whichever
+   * of them the skater takes, for `FORK_ZONE` metres, and the skater has taken one long
+   * before stretches further off than that are made.
+   */
+  update(x: number, s: number, channel: Channel): void {
     const first = Math.floor((s - BEHIND) / CHUNK)
     const last = Math.floor((s + AHEAD) / CHUNK)
     for (const [index, chunk] of this.chunks) {
@@ -118,21 +162,39 @@ export class Shores {
       this.chunks.delete(index)
     }
     for (let index = first; index <= last; index++) {
-      if (!this.chunks.has(index)) this.chunks.set(index, this.make(index))
+      if (!this.chunks.has(index)) this.chunks.set(index, this.make(index, channel))
     }
     for (const chunk of this.chunks.values()) {
       chunk.group.position.set(chunk.x - x, 0, -(chunk.s - s))
     }
   }
 
-  private make(index: number): Chunk {
-    const s = index * CHUNK
-    const x = this.lake.centre(s)
+  private make(index: number, channel: Channel): Chunk {
+    const { lake } = this
+    const s0 = index * CHUNK
+    const x0 = channel.centre(s0)
     const group = new THREE.Group()
-    group.add(new THREE.Mesh(land(this.lake, -1, x, s), snow))
-    group.add(new THREE.Mesh(land(this.lake, 1, x, s), snow))
-    group.add(trees(this.lake, this.seed, index, x, s))
+    // The left bank's columns run from far inland to the shore, the right bank's from
+    // the shore inland, and the island's from its left shore to its right.
+    const inland = INLAND.length - 1
+    group.add(new THREE.Mesh(land(INLAND.length, x0, s0, (s, col) => {
+      const d = INLAND[inland - col]!
+      return [shoreline(channel, s).left - d, lake.bankHeight(-1, s, d)]
+    }), snow))
+    group.add(new THREE.Mesh(land(INLAND.length, x0, s0, (s, col) => {
+      const d = INLAND[col]!
+      return [shoreline(channel, s).right + d, lake.bankHeight(1, s, d)]
+    }), snow))
+    group.add(new THREE.Mesh(land(ACROSS.length, x0, s0, (s, col) => {
+      const { islandLeft, islandRight } = shoreline(channel, s)
+      const width = islandRight - islandLeft
+      const t = ACROSS[col]!
+      // An island rises from both its shores, so its height is that of land as far
+      // inland as its nearer shore.
+      return [islandLeft + t * width, lake.bankHeight(1, s, Math.min(t, 1 - t) * width)]
+    }), snow))
+    group.add(trees(lake, channel, this.seed, index, x0, s0))
     this.group.add(group)
-    return { x, s, group }
+    return { x: x0, s: s0, group }
   }
 }
diff --git a/src/skater.ts b/src/skater.ts
index b0bd933..5e18769 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -1,5 +1,5 @@
 import { BREAKING_THICKNESS } from './ice/strength.js'
-import { type Lake, mulberry32 } from './world/lake.js'
+import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js'
 
 /** Cruising speed, m/s: about 25 km/h, an easy pace on good ice. */
 export const SPEED = 7
@@ -7,6 +7,25 @@ export const SPEED = 7
 export const LATERAL_SPEED = 4
 /** The autopilot never lets the skater closer to a shore than this, metres. */
 export const SHORE_MARGIN = 2
+/**
+ * Nor further from the middle of the channel than this, metres, which only matters out
+ * at sea, where the shores are far out of sight.
+ */
+export const MAX_REACH = 150
+
+/**
+ * How far from the middle of `channel` a skater at `s` may be, metres. It looks ahead:
+ * where the lake narrows faster than a skater can be steered clear, a skater at the
+ * shore would be swept sideways by it at speed, so the room starts closing early, and
+ * by no more than half a metre for each metre forward.
+ */
+function reach(channel: Channel, s: number): number {
+  let room = MAX_REACH
+  for (let ahead = 0; ahead <= 200; ahead += 20) {
+    room = Math.min(room, channel.halfWidth(s + ahead) - SHORE_MARGIN + 0.5 * ahead)
+  }
+  return room
+}
 /** Seconds without steering before the autopilot starts easing back to the middle. */
 export const IDLE_BEFORE_RETURN = 1.5
 /** Time constant of that easing, seconds. */
@@ -27,13 +46,15 @@ export function strideSeconds(index: number): number {
   return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()
 }
 
-/** Seconds between going through the ice and skating on from the middle of the lake. */
+/** Seconds between going through the ice and skating on from the middle of the channel. */
 export const UNDER_SECONDS = 4
 
 export interface Skater {
   /** Distance skated along the lake, metres. */
   s: number
-  /** Sideways distance from the middle of the channel, metres; positive is right. */
+  /** The channel of the lake the skater is in. */
+  channel: Channel
+  /** Sideways distance from the middle of that channel, metres; positive is right. */
   offset: number
   /** Sideways speed across the channel, m/s. */
   lateral: number
@@ -47,25 +68,40 @@ export interface Skater {
   under: number
 }
 
-export function createSkater(s = 0): Skater {
-  return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0, under: 0 }
+/** A skater `s` metres down the lake, having let the autopilot choose at every fork. */
+export function createSkater(lake: Lake, s = 0): Skater {
+  return { s, channel: channelAt(lake, s), offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0, under: 0 }
+}
+
+/** Where the skater is across the lake, in the same metres as `Channel.centre`. */
+export function skaterX(k: Skater): number {
+  return k.channel.centre(k.s) + k.offset
 }
 
-/** Where the skater is across the lake, in the same metres as `Lake.centre`. */
-export function skaterX(k: Skater, lake: Lake): number {
-  return lake.centre(k.s) + k.offset
+/**
+ * The other channel of the fork the skater has just come through, for as long as there
+ * is open ice between the two: until the island begins.
+ */
+function alongside(k: Skater): Channel | undefined {
+  const fork = k.channel.birth
+  if (!fork || k.s < fork.s || k.s >= fork.tip) return undefined
+  return k.channel === fork.left ? fork.right : fork.left
 }
 
 /**
  * Move the skater on by `dt` seconds. `steer` is -1 (hard left) to 1 (hard right).
  *
- * The skater is held as an offset from the middle of the channel rather than as a
+ * The skater is held as an offset from the middle of a channel rather than as a
  * position, which is what makes doing nothing an autopilot: an untouched offset follows
  * every bend. Steering moves the offset, and left alone for a while it eases back to 0.
  *
+ * At a fork the skater is put in the channel the fork prefers, and until the island
+ * between the two begins they are in whichever channel's middle they are nearer. So
+ * doing nothing takes the preferred way, and steering across takes the other.
+ *
  * Ice too thin to hold a skater (ice/strength.ts) gives way. The skater is then in the
- * water where they fell for `UNDER_SECONDS`, and skates on from the middle of the lake,
- * where the ice is always sound (world/lake.ts).
+ * water where they fell for `UNDER_SECONDS`, and skates on from the middle of the
+ * channel, where the ice is always sound (world/lake.ts).
  */
 export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): void {
   if (k.under > 0) {
@@ -73,7 +109,7 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo
     k.lateral = 0
     if (k.under === 0) {
       k.offset = 0
-      k.heading = Math.atan(lake.slope(k.s))
+      k.heading = Math.atan(k.channel.slope(k.s))
       k.idle = Infinity
     }
     return
@@ -86,16 +122,38 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo
     k.idle += dt
     if (k.idle > IDLE_BEFORE_RETURN) k.offset *= Math.exp(-dt / RETURN_TIME)
   }
-  const limit = lake.halfWidth(k.s) - SHORE_MARGIN
-  k.offset = Math.max(-limit, Math.min(limit, k.offset))
+  // The skater has the width of their own channel and, while it is still alongside, of
+  // the other channel of the fork as well.
+  const room = reach(k.channel, k.s)
+  let left = -room
+  let right = room
+  const other = alongside(k)
+  if (other) {
+    const across = other.centre(k.s) - k.channel.centre(k.s)
+    const otherReach = reach(other, k.s)
+    left = Math.min(left, across - otherReach)
+    right = Math.max(right, across + otherReach)
+  }
+  k.offset = Math.max(left, Math.min(right, k.offset))
   k.lateral = dt > 0 ? (k.offset - before) / dt : 0
 
   // Sideways metres per metre forward. The speed is along the path actually skated, so
   // a bend or a hard steer covers a little less of the lake's length.
-  const slope = lake.slope(k.s) + k.lateral / SPEED
+  const slope = k.channel.slope(k.s) + k.lateral / SPEED
   k.s += (SPEED * dt) / Math.hypot(1, slope)
   // The view turns into a change of direction over a moment rather than snapping to it.
   k.heading += (Math.atan(slope) - k.heading) * (1 - Math.exp(-dt / 0.25))
   k.stride += dt / strideSeconds(Math.floor(k.stride))
-  if (lake.thickness(skaterX(k, lake), k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS
+
+  // Which channel the skater is in can change; where they are on the ice does not.
+  const x = skaterX(k)
+  const into = (channel: Channel): void => {
+    k.channel = channel
+    k.offset = x - channel.centre(k.s)
+  }
+  if (k.s >= k.channel.end.s) into(k.channel.end.preferred)
+  const beside = alongside(k)
+  if (beside && Math.abs(x - beside.centre(k.s)) < Math.abs(k.offset)) into(beside)
+
+  if (lake.thickness(x, k.s, k.channel) < BREAKING_THICKNESS) k.under = UNDER_SECONDS
 }
diff --git a/src/world/lake.ts b/src/world/lake.ts
index 3bdf7b9..cc11b8f 100644
--- a/src/world/lake.ts
+++ b/src/world/lake.ts
@@ -12,28 +12,94 @@ export function mulberry32(seed: number): () => number {
   }
 }
 
+/** A number from 0 to 1 that is always the same for the same two whole numbers. */
+function hash(a: number, b: number): number {
+  return mulberry32(a ^ Math.imul(b + 1, 0x9e3779b1))()
+}
+
+const smooth = (t: number): number => {
+  const c = Math.max(0, Math.min(1, t))
+  return c * c * (3 - 2 * c)
+}
+const mix = (a: number, b: number, t: number): number => a + (b - a) * t
+
 /**
- * The lake is a channel that runs on for ever. Everything about it is a function of `s`,
- * the distance skated along it in metres, so any stretch can be made on demand and
- * nothing has to be remembered: where its middle is, how wide it is, how the land rises
- * from its shores.
+ * The lake is water that runs on for ever, and forks. A channel is one way through it:
+ * from the fork that made it to the fork that ends it. Everything about a channel is a
+ * function of `s`, the distance skated in metres, so any stretch can be made on demand
+ * and nothing has to be remembered.
+ *
+ * A channel answers for any `s`, not only its own stretch. Before the fork that made it,
+ * it is the channel it forked from; so the two channels of a fork are one where they part
+ * and draw away from each other after it.
  */
-export interface Lake {
+export interface Channel {
   /** Sideways position of the middle of the channel, metres. */
   centre(s: number): number
   /** Sideways metres per metre forward that the middle moves: the channel's bend. */
   slope(s: number): number
   /** From the middle to either shore, metres. */
   halfWidth(s: number): number
-  /** Height of the land `d` metres inland of the left (-1) or right (+1) shore. */
+  /** The fork that made this channel; the lake's first channel has none. */
+  readonly birth?: Fork
+  /** The fork this channel ends in. */
+  readonly end: Fork
+}
+
+/** Where one channel becomes two. */
+export interface Fork {
+  /** Where the channels part, and, further on, where the island between them begins. */
+  readonly s: number
+  readonly tip: number
+  readonly left: Channel
+  readonly right: Channel
+  /** The one a skater who does nothing is taken down. */
+  readonly preferred: Channel
+}
+
+export interface Lake {
+  /** The channel the lake starts in. */
+  readonly start: Channel
+  /** Height of the land `d` metres from the nearest shore, at `s` along. */
   bankHeight(side: -1 | 1, s: number, d: number): number
-  /** Thickness of the ice, metres, at `x` across (as `centre` is) and `s` along. */
-  thickness(x: number, s: number): number
+  /** Thickness of the ice, metres, at `x` across and `s` along, for a skater in `channel`. */
+  thickness(x: number, s: number, channel: Channel): number
 }
 
-/** The narrowest and widest the channel gets, shore to middle, in metres. */
-export const MIN_HALF_WIDTH = 12
-export const MAX_HALF_WIDTH = 44
+/**
+ * The narrowest and widest a channel gets, shore to middle, in metres. Most of the lake
+ * is `ORDINARY` wide. Now and then it pinches to a pass `MIN_HALF_WIDTH` wide, and now
+ * and then it opens until neither shore can be seen through the haze (which ends at
+ * 600 m): a stretch of sea.
+ */
+export const MIN_HALF_WIDTH = 5
+export const ORDINARY_HALF_WIDTH = [12, 44] as const
+const OPEN_TIMES = 19
+export const MAX_HALF_WIDTH = ORDINARY_HALF_WIDTH[1] * OPEN_TIMES
+
+/**
+ * How far each channel of a fork ends up from where the one channel would have run, and
+ * the distance over which it gets there, metres. Twice `FORK_APART` is always more than
+ * two ordinary channels are wide, so there is always an island between.
+ */
+const FORK_APART = 90
+const FORK_RAMP = 600
+/**
+ * Around a fork the lake keeps to its ordinary width, from this far before it, metres:
+ * a fork in a pass or out at sea would not read as one.
+ */
+const CALM_BEFORE = 250
+const CALM_FADE = 250
+/**
+ * Metres after a fork for which both its channels are part of the lake that is drawn,
+ * and so for which they keep to their ordinary width too.
+ */
+export const FORK_ZONE = 1200
+const CALM_AFTER = FORK_ZONE
+/** The first fork, and then how soon and how late after one fork the next comes, metres. */
+const FIRST_FORK = 700
+const NEXT_FORK = [1800, 4200] as const
+
 /**
  * The thinnest and thickest the ice gets, metres: from ice that will not hold a skater
  * (ice/strength.ts) to ice half as thick again as it needs to be.
@@ -41,7 +107,7 @@ export const MAX_HALF_WIDTH = 44
 export const MIN_THICKNESS = 0.014
 export const MAX_THICKNESS = 0.07
 /**
- * The ice down the middle of the lake is never thinner than this, metres, which is what
+ * The ice down the middle of a channel is never thinner than this, metres, which is what
  * lets the autopilot skate for ever: it is the 4.1 cm of the film this is modelled on
  * (tools/analyse-video.mjs), sound ice that sings.
  */
@@ -50,32 +116,98 @@ export const MIDDLE_THICKNESS = 0.041
 const MIDDLE_HOLDS = 3
 const MIDDLE_FADES = 8
 
+/** The channels the lake has at `s`, left to right, for a skater who is in `channel`. */
+export function channelsAt(channel: Channel, s: number): Channel[] {
+  const { birth, end } = channel
+  if (birth && s >= birth.s && s < birth.s + FORK_ZONE) return [birth.left, birth.right]
+  if (s >= end.s) return [end.left, end.right]
+  return [channel]
+}
+
+/** The channel a skater who has done nothing since the start is in at `s`. */
+export function channelAt(lake: Lake, s: number): Channel {
+  let channel = lake.start
+  while (channel.end.s <= s) channel = channel.end.preferred
+  return channel
+}
+
 export function createLake(seed: number): Lake {
   const noise = createNoise2D(mulberry32(seed))
-  // A long wander with a shorter wiggle on it. The amplitudes over the wavelengths keep
-  // the bend gentle enough to skate without ever braking.
-  const centre = (s: number): number => 45 * noise(s / 600, 0) + 6 * noise(s / 180, 10)
-  const mid = (MIN_HALF_WIDTH + MAX_HALF_WIDTH) / 2
-  const swing = (MAX_HALF_WIDTH - MIN_HALF_WIDTH) / 2
+  // A long wander with a shorter wiggle on it, which every channel shares. The amplitudes
+  // over the wavelengths keep the bend gentle enough to skate without ever braking.
+  const wander = (s: number): number => 45 * noise(s / 600, 0) + 6 * noise(s / 180, 10)
+
+  /**
+   * One way through the lake. `lane` is a number of its own, which is what makes its
+   * width and its forks unlike any other channel's; `shift` is how far to one side of
+   * the lake's wander it runs.
+   */
+  function channel(lane: number, shift: number, birth?: Fork, parent?: Channel, parentShift = 0): Channel {
+    // Somewhere of its own in the noise.
+    const key = (lane % 9973) * 3.1
+    const born = birth?.s ?? -Infinity
+    const ends = birth ? born + mix(NEXT_FORK[0], NEXT_FORK[1], hash(lane, 0)) : FIRST_FORK
+    /** 0 where the channel leaves the one it forked from, 1 once it has drawn clear. */
+    const away = (s: number): number => (birth ? smooth((s - born) / FORK_RAMP) : 1)
+    /** 1 near either of the channel's forks, where it keeps to its ordinary width. */
+    const calm = (s: number): number =>
+      Math.max(...[born, ends].map((f) => Math.min(smooth((s - (f - CALM_BEFORE - CALM_FADE)) / CALM_FADE),
+        smooth((f + CALM_AFTER + CALM_FADE - s) / CALM_FADE))))
+
+    const ownWidth = (s: number): number => {
+      const [lo, hi] = ORDINARY_HALF_WIDTH
+      const ordinary = (lo + hi) / 2 + ((hi - lo) / 2) * (0.75 * noise(s / 300, key + 50) + 0.25 * noise(s / 90, key + 80))
+      // The lake's character changes over kilometres: a pass, ordinary water, open sea.
+      const character = noise(s / 2500, key + 7) * (1 - calm(s))
+      const pass = smooth((-0.22 - character) / 0.25)
+      const open = smooth((character - 0.03) / 0.27)
+      return mix(ordinary, MIN_HALF_WIDTH, pass) * (1 + (OPEN_TIMES - 1) * open * open)
+    }
+
+    const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(s))
+    let end: Fork | undefined
+    const self: Channel = {
+      centre,
+      slope: (s) => centre(s + 0.5) - centre(s - 0.5),
+      halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s)),
+      birth,
+      get end() {
+        return (end ??= fork(self, lane, shift, ends))
+      },
+    }
+    return self
+  }
+
+  function fork(trunk: Channel, lane: number, shift: number, s: number): Fork {
+    const made = {} as { -readonly [K in keyof Fork]: Fork[K] }
+    made.s = s
+    made.left = channel(Math.floor(hash(lane, 1) * 2 ** 32), shift - FORK_APART, made, trunk, shift)
+    made.right = channel(Math.floor(hash(lane, 2) * 2 ** 32), shift + FORK_APART, made, trunk, shift)
+    made.preferred = hash(lane, 3) < 0.5 ? made.left : made.right
+    // The island begins where the two channels' facing shores first come apart.
+    made.tip = s
+    while (made.left.centre(made.tip) + made.left.halfWidth(made.tip)
+      >= made.right.centre(made.tip) - made.right.halfWidth(made.tip)) made.tip += 2
+    return made
+  }
+
   return {
-    centre,
-    slope: (s) => centre(s + 0.5) - centre(s - 0.5),
-    halfWidth: (s) => mid + swing * (0.75 * noise(s / 300, 50) + 0.25 * noise(s / 90, 80)),
+    start: channel(seed >>> 0, 0),
     bankHeight: (side, s, d) => {
       // A bank that climbs quickly out of the ice and then a slow rise into hills, taller
       // in some places than others. Zero at the shore, so the land meets the ice exactly.
       const rise = 8 * (1 - Math.exp(-d / 25)) + 0.06 * d
       return rise * (0.55 + 0.45 * noise(s / 140, side * 37 + d / 90))
     },
-    thickness: (x, s) => {
+    thickness: (x, s, channel) => {
       // Ice thickens and thins in broad patches, longer along the lake than across it.
       const patch = (MIN_THICKNESS + MAX_THICKNESS) / 2 + ((MAX_THICKNESS - MIN_THICKNESS) / 2) * noise(x / 60 + 500, s / 180)
       if (patch >= MIDDLE_THICKNESS) return patch
-      // Where a thin patch crosses the middle of the lake, a strip of sound ice bridges it.
-      const off = Math.abs(x - centre(s))
-      const bridge = 1 - Math.min(1, Math.max(0, (off - MIDDLE_HOLDS) / (MIDDLE_FADES - MIDDLE_HOLDS)))
+      // Where a thin patch crosses the middle of a channel, a strip of sound ice bridges it.
+      const off = Math.min(...channelsAt(channel, s).map((c) => Math.abs(x - c.centre(s))))
+      const bridge = smooth(1 - (off - MIDDLE_HOLDS) / (MIDDLE_FADES - MIDDLE_HOLDS))
       if (bridge === 1) return MIDDLE_THICKNESS
-      return patch + (MIDDLE_THICKNESS - patch) * bridge * bridge * (3 - 2 * bridge)
+      return mix(patch, MIDDLE_THICKNESS, bridge)
     },
   }
 }
diff --git a/test/lake.test.ts b/test/lake.test.ts
index b76dc44..33472d1 100644
--- a/test/lake.test.ts
+++ b/test/lake.test.ts
@@ -1,40 +1,68 @@
 import { describe, expect, it } from 'vitest'
 import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js'
 import {
-  MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, createLake,
+  type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS,
+  ORDINARY_HALF_WIDTH, channelAt, channelsAt, createLake,
 } from '../src/world/lake.js'
 
+const lake = createLake(1)
+
+/** Go down the lake as a skater who does nothing would, calling `see` every `step` metres. */
+function follow(to: number, step: number, see: (channel: Channel, s: number) => void): void {
+  let channel = lake.start
+  for (let s = 0; s < to; s += step) {
+    while (channel.end.s <= s) channel = channel.end.preferred
+    see(channel, s)
+  }
+}
+
+/** The first `count` forks that skater comes to. */
+function forks(count: number): Channel['end'][] {
+  const found = []
+  for (let channel = lake.start; found.length < count; channel = channel.end.preferred) found.push(channel.end)
+  return found
+}
+
 describe('lake', () => {
   it('is the same lake for the same seed, and another for another', () => {
-    const a = createLake(7)
-    const b = createLake(7)
-    const c = createLake(8)
+    const again = createLake(1)
+    const other = createLake(2)
     for (const s of [0, 123.4, 5_000, 1e7]) {
-      expect(a.centre(s)).toBe(b.centre(s))
-      expect(a.halfWidth(s)).toBe(b.halfWidth(s))
+      expect(channelAt(again, s).centre(s)).toBe(channelAt(lake, s).centre(s))
+      expect(channelAt(again, s).halfWidth(s)).toBe(channelAt(lake, s).halfWidth(s))
     }
-    expect(a.centre(123.4)).not.toBe(c.centre(123.4))
+    expect(other.start.centre(123.4)).not.toBe(lake.start.centre(123.4))
   })
 
-  it('stays within its narrowest and widest, however far it runs', () => {
-    const lake = createLake(1)
-    for (let s = 0; s < 200_000; s += 7) {
-      const w = lake.halfWidth(s)
-      expect(w).toBeGreaterThanOrEqual(MIN_HALF_WIDTH)
+  it('is mostly ordinary water, with narrow passes and stretches of open sea', () => {
+    let pass = 0
+    let ordinary = 0
+    let sea = 0
+    let all = 0
+    follow(400_000, 10, (channel, s) => {
+      const w = channel.halfWidth(s)
+      expect(w).toBeGreaterThanOrEqual(MIN_HALF_WIDTH - 1e-9)
       expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH)
-    }
+      // A pass: you could throw a snowball across it. Sea: neither shore within the haze.
+      if (w < 8) pass++
+      else if (w > 600) sea++
+      else if (w <= ORDINARY_HALF_WIDTH[1]) ordinary++
+      all++
+    })
+    console.info(`pass ${((100 * pass) / all).toFixed(1)}%, ordinary ${((100 * ordinary) / all).toFixed(1)}%, sea ${((100 * sea) / all).toFixed(1)}%`)
+    expect(ordinary / all).toBeGreaterThan(0.5)
+    expect(pass / all).toBeGreaterThan(0.03)
+    expect(sea / all).toBeGreaterThan(0.03)
   })
 
-  it('never bends too sharply to skate at full speed', () => {
-    const lake = createLake(1)
+  it('never bends too sharply to skate at full speed, forks included', () => {
     let steepest = 0
-    for (let s = 0; s < 200_000; s += 3) steepest = Math.max(steepest, Math.abs(lake.slope(s)))
-    // 0.7 sideways per metre forward is a 35 degree turn off straight ahead.
-    expect(steepest).toBeLessThan(0.7)
+    follow(300_000, 3, (channel, s) => (steepest = Math.max(steepest, Math.abs(channel.slope(s)))))
+    // 0.85 sideways per metre forward is a 40 degree turn off straight ahead.
+    expect(steepest).toBeLessThan(0.85)
   })
 
   it('has land that meets the ice at the shore and rises inland', () => {
-    const lake = createLake(1)
     for (const side of [-1, 1] as const) {
       for (let s = 0; s < 5_000; s += 50) {
         expect(lake.bankHeight(side, s, 0)).toBe(0)
@@ -43,45 +71,93 @@ describe('lake', () => {
     }
   })
 
+  it('forks into two channels that start as one and draw apart round an island', () => {
+    for (const fork of forks(40)) {
+      const { s, tip, left, right } = fork
+      // At the fork both are still the channel they came from.
+      expect(left.centre(s)).toBe(right.centre(s))
+      expect(left.halfWidth(s)).toBe(right.halfWidth(s))
+      expect(left.birth).toBe(fork)
+      expect([left, right]).toContain(fork.preferred)
+      // The island begins soon after, and from then on there is always land between.
+      expect(tip - s).toBeGreaterThan(50)
+      expect(tip - s).toBeLessThan(400)
+      for (let at = tip + 20; at < s + FORK_ZONE; at += 10) {
+        const island = right.centre(at) - right.halfWidth(at) - (left.centre(at) + left.halfWidth(at))
+        expect(island).toBeGreaterThan(at < tip + 100 ? 0 : 20)
+      }
+      // Both are ordinary water for as long as both are drawn: a fork reads as a fork.
+      for (let at = s - 300; at < s + FORK_ZONE; at += 25) {
+        for (const channel of [left, right]) expect(channel.halfWidth(at)).toBeLessThanOrEqual(ORDINARY_HALF_WIDTH[1] + 1e-9)
+      }
+    }
+  })
+
+  it('comes to a fork soon, and then every few kilometres', () => {
+    const [first, ...rest] = forks(40).map((fork) => fork.s)
+    expect(first).toBeLessThan(1000)
+    let before = first!
+    for (const s of rest) {
+      expect(s - before).toBeGreaterThanOrEqual(1800)
+      expect(s - before).toBeLessThanOrEqual(4200)
+      before = s
+    }
+  })
+
+  it('draws both channels of a fork, and then only the one taken', () => {
+    const fork = lake.start.end
+    expect(channelsAt(lake.start, fork.s - 1)).toEqual([lake.start])
+    expect(channelsAt(lake.start, fork.s + 1)).toEqual([fork.left, fork.right])
+    for (const taken of [fork.left, fork.right]) {
+      expect(channelsAt(taken, fork.s - 1)).toEqual([taken])
+      expect(channelsAt(taken, fork.s + FORK_ZONE - 1)).toEqual([fork.left, fork.right])
+      expect(channelsAt(taken, fork.s + FORK_ZONE + 1)).toEqual([taken])
+      // Before the fork, a channel is the one it forked from.
+      expect(taken.centre(fork.s - 200)).toBe(lake.start.centre(fork.s - 200))
+      expect(taken.halfWidth(fork.s - 200)).toBe(lake.start.halfWidth(fork.s - 200))
+    }
+  })
+
   it('has ice between its thinnest and thickest everywhere', () => {
-    const lake = createLake(1)
-    for (let s = 0; s < 50_000; s += 13) {
-      for (const x of [-80, -20, 0, 35, 90]) {
-        const h = lake.thickness(x, s)
+    follow(50_000, 13, (channel, s) => {
+      for (const off of [-80, -20, 0, 35, 90]) {
+        const h = lake.thickness(channel.centre(s) + off, s, channel)
         expect(h).toBeGreaterThanOrEqual(MIN_THICKNESS)
         expect(h).toBeLessThanOrEqual(MAX_THICKNESS)
       }
-    }
+    })
   })
 
-  it('always has sound ice down its middle', () => {
-    const lake = createLake(1)
-    for (let s = 0; s < 200_000; s += 5) {
-      for (const off of [-2.9, 0, 2.9]) expect(lake.thickness(lake.centre(s) + off, s)).toBeGreaterThanOrEqual(MIDDLE_THICKNESS)
-    }
+  it('always has sound ice down the middle of every channel', () => {
+    follow(200_000, 5, (channel, s) => {
+      for (const each of channelsAt(channel, s)) {
+        for (const off of [-2.9, 0, 2.9]) {
+          expect(lake.thickness(each.centre(s) + off, s, channel)).toBeGreaterThanOrEqual(MIDDLE_THICKNESS)
+        }
+      }
+    })
   })
 
   it('has every kind of ice within reach of a skater, most of it sound', () => {
-    const lake = createLake(1)
     const zones = { breaking: 0, cracking: 0, singing: 0, safe: 0 }
     let all = 0
-    for (let s = 0; s < 100_000; s += 10) {
-      const reach = lake.halfWidth(s) - 2
-      for (let off = -reach; off <= reach; off += 2) {
-        const h = lake.thickness(lake.centre(s) + off, s)
+    follow(100_000, 10, (channel, s) => {
+      const reach = channel.halfWidth(s) - 2
+      for (let i = -10; i <= 10; i++) {
+        const h = lake.thickness(channel.centre(s) + (reach * i) / 10, s, channel)
         if (h < BREAKING_THICKNESS) zones.breaking++
         else if (h < CRACKING_THICKNESS) zones.cracking++
         else if (h < SAFE_THICKNESS) zones.singing++
         else zones.safe++
         all++
       }
-    }
+    })
+    console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', '))
     // Ice that gives way is there to be found, and rare enough to be avoided.
     expect(zones.breaking / all).toBeGreaterThan(0.005)
     expect(zones.breaking / all).toBeLessThan(0.08)
     expect(zones.cracking / all).toBeGreaterThan(0.1)
     expect(zones.singing / all).toBeGreaterThan(0.2)
     expect(zones.safe / all).toBeGreaterThan(0.2)
-    console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', '))
   })
 })
diff --git a/test/skater.test.ts b/test/skater.test.ts
index 1d76e7d..8acb874 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -1,36 +1,46 @@
 import { describe, expect, it } from 'vitest'
+import { BREAKING_THICKNESS } from '../src/ice/strength.js'
 import {
-  IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS,
-  createSkater, skaterX, stepSkater, strideSeconds,
+  IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_REACH, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS,
+  type Skater, createSkater, skaterX, stepSkater, strideSeconds,
 } from '../src/skater.js'
-import { BREAKING_THICKNESS } from '../src/ice/strength.js'
 import { type Lake, createLake } from '../src/world/lake.js'
 
 const DT = 1 / 60
 const lake = createLake(1)
+/** The same lake with ice that holds everywhere, for trying what is not about thin ice. */
+const sound: Lake = { ...lake, thickness: () => 0.05 }
 
-function run(k: ReturnType<typeof createSkater>, steer: number, seconds: number): void {
+function run(k: Skater, steer: number, seconds: number, on = sound): void {
   const steps = Math.round(seconds / DT)
-  for (let i = 0; i < steps; i++) stepSkater(k, lake, steer, DT)
+  for (let i = 0; i < steps; i++) stepSkater(k, on, steer, DT)
 }
 
 describe('skater', () => {
-  it('left alone, follows the middle of the channel for as long as you like', () => {
-    const k = createSkater()
+  it('left alone, follows the middle of the lake for as long as you like, through every fork', () => {
+    const k = createSkater(lake)
+    let forks = 0
     for (let t = 0; t < 3600; t += DT) {
       const before = k.s
+      const channel = k.channel
       stepSkater(k, lake, 0, DT)
       expect(k.s).toBeGreaterThan(before)
-      expect(k.offset).toBe(0)
+      expect(Math.abs(k.offset)).toBeLessThan(1e-3)
+      expect(k.under).toBe(0)
+      if (k.channel !== channel) {
+        // Doing nothing takes the way the fork prefers.
+        expect(k.channel).toBe(channel.end.preferred)
+        forks++
+      }
     }
-    expect(skaterX(k, lake)).toBe(lake.centre(k.s))
+    expect(forks).toBeGreaterThan(5)
     // An hour at cruising speed, less a little for the bends.
     expect(k.s).toBeGreaterThan(SPEED * 3600 * 0.9)
     expect(k.s).toBeLessThanOrEqual(SPEED * 3600 + 1)
   })
 
   it('steers across the channel at the steering speed', () => {
-    const k = createSkater()
+    const k = createSkater(lake)
     run(k, 1, 1)
     expect(k.offset).toBeCloseTo(LATERAL_SPEED, 1)
     expect(k.heading).toBeGreaterThan(0)
@@ -38,19 +48,52 @@ describe('skater', () => {
     expect(k.offset).toBeCloseTo(-LATERAL_SPEED, 1)
   })
 
-  it('cannot be steered into a shore', () => {
+  it('cannot be steered into a shore, however wide or narrow the lake', () => {
     for (const steer of [-1, 1]) {
-      const k = createSkater()
-      for (let t = 0; t < 120; t += DT) {
-        stepSkater(k, lake, steer, DT)
-        expect(Math.abs(k.offset)).toBeLessThanOrEqual(lake.halfWidth(k.s) - SHORE_MARGIN + 0.2)
+      const k = createSkater(lake)
+      let x = skaterX(k)
+      for (let t = 0; t < 1800; t += DT) {
+        stepSkater(k, sound, steer, DT)
+        // Never past a shore; held hard against one; and never moved at a jump.
+        if (!k.channel.birth || k.s > k.channel.birth.tip) {
+          expect(Math.abs(k.offset)).toBeLessThanOrEqual(Math.min(MAX_REACH, k.channel.halfWidth(k.s) - SHORE_MARGIN) + 0.5)
+        }
+        expect(Math.abs(skaterX(k) - x)).toBeLessThan(0.3)
+        x = skaterX(k)
       }
-      expect(Math.sign(k.offset)).toBe(steer)
     }
   })
 
+  it('takes the left of a fork when steered left and the right when steered right', () => {
+    const fork = lake.start.end
+    for (const [steer, wanted] of [[-1, fork.left], [1, fork.right]] as const) {
+      const k = createSkater(lake, fork.s - 30)
+      while (k.s < fork.tip + 50) stepSkater(k, sound, steer, DT)
+      expect(k.channel).toBe(wanted)
+      // And from there on left alone, stays in it, in its middle.
+      run(k, 0, 60)
+      expect(k.channel).toBe(wanted)
+      expect(Math.abs(k.offset)).toBeLessThan(0.01)
+    }
+  })
+
+  it('can change its mind at a fork until the island begins, and not after', () => {
+    const fork = lake.start.end
+    const other = fork.preferred === fork.left ? fork.right : fork.left
+    const towards = other === fork.right ? 1 : -1
+    const k = createSkater(lake, fork.s - 1)
+    run(k, 0, 1)
+    expect(k.channel).toBe(fork.preferred)
+    while (k.channel !== other) stepSkater(k, sound, towards, DT)
+    expect(k.s).toBeLessThan(fork.tip)
+    // Past the island's tip, steering back runs along its shore instead.
+    while (k.s < fork.tip + 5) stepSkater(k, sound, towards, DT)
+    run(k, -towards, 30)
+    expect(k.channel).toBe(other)
+  })
+
   it('holds its line for a moment after steering, then eases back to the middle', () => {
-    const k = createSkater()
+    const k = createSkater(lake)
     run(k, 1, 1)
     const steered = k.offset
     run(k, 0, IDLE_BEFORE_RETURN - 0.1)
@@ -69,29 +112,21 @@ describe('skater', () => {
     // Varied: they fill the range rather than sitting at one length.
     expect(Math.max(...lengths) - Math.min(...lengths)).toBeGreaterThan(0.8 * (MAX_STRIDE - MIN_STRIDE))
     // And the skater takes them at that pace: the first three strides, to the frame.
-    const k = createSkater()
+    const k = createSkater(lake)
     run(k, 0, lengths[0]! + lengths[1]! + lengths[2]! + 2 * DT)
     expect(Math.floor(k.stride)).toBe(3)
   })
 
   it('skates the same far down the lake as at its start', () => {
-    const k = createSkater(1e7)
+    const k = createSkater(lake, 1e6)
     run(k, 0, 10)
-    expect(k.s - 1e7).toBeGreaterThan(SPEED * 10 * 0.9)
-  })
-
-  it('left alone, never goes through the ice', () => {
-    const k = createSkater()
-    for (let t = 0; t < 3600; t += DT) {
-      stepSkater(k, lake, 0, DT)
-      expect(k.under).toBe(0)
-    }
+    expect(k.s - 1e6).toBeGreaterThan(SPEED * 10 * 0.9)
   })
 
   it('goes through ice too thin to hold, waits in the water, and skates on from the middle', () => {
     // A lake that is sound down the middle and gives way anywhere 5 m to the right of it.
-    const thin: Lake = { ...lake, thickness: (x, s) => (x - lake.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) }
-    const k = createSkater()
+    const thin: Lake = { ...lake, thickness: (x, s, channel) => (x - channel.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) }
+    const k = createSkater(lake)
     while (k.under === 0) stepSkater(k, thin, 1, DT)
     expect(k.offset).toBeGreaterThan(5)
     const where = k.s
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 5e15bde..776e4fb 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -22,13 +22,17 @@ const SHOTS = [
       await page.waitForTimeout(1500)
     },
   },
-  // Ice too thin to hold lies to the right of the middle here: black, where sound ice is milky.
-  { name: 'thin-ice', query: '?s=3035', viewport: { width: 844, height: 390 }, act: async () => {} },
+  // The first fork: the island between its two channels is a little way ahead.
+  { name: 'fork', query: '?s=790', viewport: { width: 844, height: 390 }, act: async () => {} },
+  { name: 'pass', query: '?s=6400', act: async () => {} },
+  { name: 'sea', query: '?s=3400', viewport: { width: 844, height: 390 }, act: async () => {} },
+  // Ice too thin to hold lies to the left of the middle here: black, where sound ice is milky.
+  { name: 'thin-ice', query: '?s=2795', viewport: { width: 844, height: 390 }, act: async () => {} },
   {
     name: 'fell',
-    query: '?s=3055',
+    query: '?s=2815',
     act: async (page) => {
-      await page.keyboard.down('ArrowRight')
+      await page.keyboard.down('ArrowLeft')
       await page.waitForFunction(() => document.querySelector('#water').classList.contains('under'), null, { timeout: 15_000 })
       await page.waitForTimeout(600)
     },