fuzzyworld

nordic-skating / commit

feat: islets of reeds to skate round, and a skater who can turn right round

4f816cf08087eaf6d522121aa5c4a438615f3cd4 · Pavel Safronov · 2026-10-09 22:31:24 -0700 · parents 38bb1e3e1186

Claude Code usage: this commit ~25.9K out / ~7.5M cached · cost unknown (no per-model trailer) · session 1 tally 890 in / 763.1K out / 98.7M cached · usage by commit and session

feat: islets of reeds to skate round, and a skater who can turn right round

Islets, as in the film, where the skater weaves between them: reeds on
humps of frozen ground, 5 to 15 m across, in beds, with ice all round
each and none in a narrow pass or near a fork. The autopilot steers
round them, a skater steering at one is turned aside at the last
moment, and nobody can get onto one.

The limit on how far off the lake's line the skater could point is gone
from open water. Held over, the skater circles, and can skate back up
the lake; let go and the autopilot turns them down it again. The land,
the map of the ice's thickness and the view follow whichever way the
skater faces, and their room closes ahead of a narrows in either
direction.

A touch chooses at a fork: steering one way within 200 m of it is taken
as asking for that side. Holding the steer until the fork, as before,
would now be a circle.

Also fixed: trees floating above the ground. A tree was stood at the
height of the land where it is, but the land is drawn flat between the
points of its mesh, which far inland are far apart, and the two differ
by a metre or more; and the left bank's trees used the right bank's
heights. Trees now stand on the mesh.

Usage: 890 in / 763.1K out / 98.7M cached (session tally)
rtk:   1.7M saved, 25.3% over 2676 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 index.html           |   6 ++-
 src/main.ts          |   9 ++--
 src/render/ice.ts    |  18 ++++++--
 src/render/shores.ts | 128 +++++++++++++++++++++++++++++++++++++++++++--------
 src/skater.ts        | 127 +++++++++++++++++++++++++++++++++++++++-----------
 src/world/lake.ts    |  53 +++++++++++++++++++++
 test/lake.test.ts    |  31 +++++++++++++
 test/skater.test.ts  | 100 +++++++++++++++++++++++++++++++---------
 8 files changed, 395 insertions(+), 77 deletions(-)

diff --git a/index.html b/index.html
index 5becc4b..89fa5ed 100644
--- a/index.html
+++ b/index.html
@@ -31,9 +31,11 @@
   <h2>Controls</h2>
   <dl>
     <dt>Steer</dt>
-    <dd>Drag left or right from wherever you touch, or use ← → or A D. Let go and the skater straightens up, then drifts back to its own line.</dd>
+    <dd>Drag left or right from wherever you touch, or use ← → or A D. Let go and the skater straightens up, then drifts back to its own line. Hold it over and the skater turns right round: there is room for a circle wherever the water is wide.</dd>
     <dt>Choose a way at a fork</dt>
-    <dd>Steer towards it before the island begins. Do nothing and the skater picks one.</dd>
+    <dd>A touch towards it on the way up to the fork is enough. Do nothing and the skater picks one.</dd>
+    <dt>Islets</dt>
+    <dd>Tufts of reeds standing in the ice. The skater goes round them unasked; you can weave between them, or circle one.</dd>
     <dt>Sound on or off</dt>
     <dd>The button at the top right, or M.</dd>
     <dt>Another lake</dt>
diff --git a/src/main.ts b/src/main.ts
index 1ed6585..78e7aa6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -313,10 +313,13 @@ renderer.setAnimationLoop(() => {
   // The eyes lead where the lake goes, and turn most of the way into the skater's own
   // direction: a skater cutting towards a shore still watches the lake ahead.
   const along = Math.atan(skater.channel.slope(skater.s))
-  const look = along + 0.75 * (skater.heading - along)
+  // (By the sine of the angle between them, which does the same for a skater who has
+  // turned right round and is, for the moment, heading back up it.)
+  const look = skater.heading - 0.25 * Math.sin(skater.heading - along)
   camera.rotation.set(-pitch, -look, -0.02 * skater.turn * skater.speed)
 
-  ice.update(skater.x, skater.s)
-  shores.update(skater.x, skater.s, skater.channel)
+  const facing = Math.cos(look - along)
+  ice.update(skater.x, skater.s, facing)
+  shores.update(skater.x, skater.s, skater.channel, facing)
   renderer.render(scene, camera)
 })
diff --git a/src/render/ice.ts b/src/render/ice.ts
index 99e1a01..c6e4bac 100644
--- a/src/render/ice.ts
+++ b/src/render/ice.ts
@@ -20,6 +20,7 @@ const SIZE = 3200
  */
 const MAP_ACROSS = 1536
 const MAP_ALONG = 768
+/** How much of it is behind a skater who looks down the lake; the rest is ahead. */
 const MAP_BEHIND = 96
 const MAP_STEP_ACROSS = 12
 const MAP_STEP = 4
@@ -160,14 +161,20 @@ export class Ice {
     ;(this.mesh.material as THREE.MeshLambertMaterial).emissiveIntensity = strength
   }
 
-  /** 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.
+   * `facing` is 1 for a skater looking down the lake, -1 for one looking back up it.
+   */
+  update(x: number, s: number, facing = 1): 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) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s)
+    // The map lies ahead of the skater whichever way that is, and round them when they
+    // look across the lake. It is redrawn when where it should start has moved on.
+    const behind = facing > 0.5 ? MAP_BEHIND : facing < -0.5 ? MAP_ALONG - MAP_BEHIND : MAP_ALONG / 2
+    if (!(Math.abs(s - behind - this.mapS) < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s - behind)
     // 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,
@@ -175,9 +182,10 @@ export class Ice {
     )
   }
 
-  private drawMap(x: number, s: number): void {
+  /** Draw the map for a skater at `x` across, starting `from` metres along. */
+  private drawMap(x: number, from: number): 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.mapS = Math.floor(from / 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
diff --git a/src/render/shores.ts b/src/render/shores.ts
index e8b1b37..64be6fe 100644
--- a/src/render/shores.ts
+++ b/src/render/shores.ts
@@ -10,16 +10,30 @@ const ROWS = CHUNK / STEP
 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
+/**
+ * How far the land is made in the direction the skater faces, and how far the other way,
+ * metres. The fog ends at 600. Facing across the lake, it is made that far both ways.
+ */
 const AHEAD = 700
+const BEHIND = 100
+/** Stretches made in one frame at most, so that turning round does not make them all at once. */
+const MADE_AT_ONCE = 2
 const TREES_PER_BANK = 60
 const TREES_PER_ISLAND = 50
+/** Reeds on an islet for each square metre of it, and the most on one. */
+const REEDS_PER_M2 = 2.5
+const MOST_REEDS = 260
 
 const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 })
 const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true })
 // A spruce is a cone standing on its base.
 const cone = new THREE.ConeGeometry(1.7, 8, 6).translate(0, 4, 0)
+// An islet is a low hump of frozen ground, white with frost, with reeds standing in it:
+// dry, straw-coloured, a metre or two tall, and thin.
+const frost = new THREE.MeshStandardMaterial({ color: 0xd9d6c8, roughness: 1 })
+const straw = new THREE.MeshStandardMaterial({ color: 0xb59a62, roughness: 1 })
+const hump = new THREE.SphereGeometry(1, 12, 5, 0, Math.PI * 2, 0, Math.PI / 2).scale(1, 0.14, 1)
+const reed = new THREE.ConeGeometry(0.045, 1, 3).translate(0, 0.5, 0)
 
 interface Chunk {
   /** Where the chunk's own origin is: across the lake, and along it. */
@@ -95,13 +109,42 @@ function land(columns: number, x0: number, s0: number, place: (s: number, col: n
   return geometry
 }
 
+/**
+ * How high a strip of land's mesh is at `s` along and `at` across, where `columns` is
+ * what its columns are placed by and `height` the height it was given at a row and a
+ * column. Not the height of the land itself there: the mesh is flat between its rows and
+ * columns and the land curves, so the two differ by a metre or more far inland, where
+ * the columns are far apart. Whatever stands on the land stands on its mesh.
+ */
+function meshHeight(s0: number, s: number, columns: readonly number[], at: number, height: (s: number, column: number) => number): number {
+  let col = 0
+  while (col < columns.length - 2 && columns[col + 1]! < at) col++
+  const across = Math.max(0, Math.min(1, (at - columns[col]!) / (columns[col + 1]! - columns[col]!)))
+  const row = Math.floor((s - s0) / STEP)
+  const along = (s - s0) / STEP - row
+  const atRow = (r: number): number => {
+    const rowS = s0 + r * STEP
+    return height(rowS, columns[col]!) * (1 - across) + height(rowS, columns[col + 1]!) * across
+  }
+  return atRow(row) * (1 - along) + atRow(row + 1) * along
+}
+
+/** How high the island between two channels is at `s`, a fraction `t` of the way across it. */
+function islandHeight(lake: Lake, channel: Channel, s: number, t: number): number {
+  const { islandLeft, islandRight } = shoreline(channel, s)
+  // An island rises from both its shores, so its height is that of land as far inland
+  // as its nearer shore.
+  return lake.bankHeight(1, s, Math.min(t, 1 - t) * (islandRight - islandLeft))
+}
+
 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_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))
+  const plant = (x: number, s: number, ground: number, size: number): void => {
+    // A little into the ground, so that no tree shows its base on a slope.
+    at.position.set(x - x0, ground - 0.3, -(s - s0))
     at.rotation.y = random() * Math.PI
     at.scale.set(size, size * (0.8 + random() * 0.5), size)
     at.updateMatrix()
@@ -113,7 +156,8 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu
       const shore = shoreline(channel, s)
       // Thick along the shore and thinning out up the hill.
       const d = 5 + random() ** 2 * 170
-      plant((side < 0 ? shore.left : shore.right) + side * d, s, d, 0.7 + random() * 0.9)
+      const ground = meshHeight(s0, s, INLAND, d, (rowS, inland) => lake.bankHeight(side, rowS, inland))
+      plant((side < 0 ? shore.left : shore.right) + side * d, s, ground, 0.7 + random() * 0.9)
     }
   }
   for (let i = 0; i < TREES_PER_ISLAND; i++) {
@@ -121,14 +165,52 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu
     const { islandLeft, islandRight } = shoreline(channel, s)
     const width = islandRight - islandLeft
     const x = islandLeft + 2 + random() * (width - 4)
+    const ground = width > 8 ? meshHeight(s0, s, ACROSS, (x - islandLeft) / width, (rowS, t) => islandHeight(lake, channel, rowS, t)) : 0
     // 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)
+    plant(x, s, ground, width > 8 ? 0.7 + random() * 0.9 : 0)
   }
   return mesh
 }
 
+/** The islets of one stretch: their humps, and the reeds on them. */
+function islets(channel: Channel, x0: number, s0: number): THREE.Object3D[] {
+  const found = channel.islets(s0, s0 + CHUNK)
+  if (found.length === 0) return []
+  const reeds = found.map((islet) => Math.min(MOST_REEDS, Math.round(REEDS_PER_M2 * Math.PI * islet.radius ** 2)))
+  const humps = new THREE.InstancedMesh(hump, frost, found.length)
+  const stand = new THREE.InstancedMesh(reed, straw, reeds.reduce((a, b) => a + b))
+  const at = new THREE.Object3D()
+  let n = 0
+  for (const [i, islet] of found.entries()) {
+    const random = mulberry32(islet.seed)
+    at.position.set(islet.x - x0, 0, -(islet.s - s0))
+    at.rotation.set(0, 0, 0)
+    at.scale.set(islet.radius, islet.radius, islet.radius)
+    at.updateMatrix()
+    humps.setMatrixAt(i, at.matrix)
+    for (let r = 0; r < reeds[i]!; r++) {
+      // Thickest in the middle; none on the last of the hump, where the ice begins.
+      const out = Math.sqrt(random()) * 0.85
+      const round = random() * Math.PI * 2
+      at.position.set(
+        islet.x - x0 + Math.cos(round) * out * islet.radius,
+        // The hump is part of a sphere, squashed: this is how high it is that far out.
+        0.14 * islet.radius * Math.sqrt(1 - out * out) - 0.05,
+        -(islet.s - s0) + Math.sin(round) * out * islet.radius,
+      )
+      // Each leaning a little its own way.
+      at.rotation.set((random() - 0.5) * 0.35, random() * Math.PI, (random() - 0.5) * 0.35)
+      const tall = 0.9 + random() * 1.1
+      at.scale.set(1, tall, 1)
+      at.updateMatrix()
+      stand.setMatrixAt(n++, at.matrix)
+    }
+  }
+  return [humps, stand]
+}
+
 /**
- * The land of the lake: both banks, and the island wherever it has forked. It is made
+ * The land of the lake: both banks, the island wherever it has forked, and the islets. 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.
@@ -136,6 +218,8 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu
 export class Shores {
   readonly group = new THREE.Group()
   private readonly chunks = new Map<number, Chunk>()
+  /** Whether nothing of this lake has been made yet. */
+  private starting = true
 
   constructor(private lake: Lake, private seed: number) {}
 
@@ -145,6 +229,7 @@ export class Shores {
     this.seed = seed
     for (const chunk of this.chunks.values()) this.drop(chunk)
     this.chunks.clear()
+    this.starting = true
   }
 
   private drop(chunk: Chunk): void {
@@ -158,24 +243,34 @@ export class Shores {
 
   /**
    * Make, drop and place the stretches for a skater at `x` across and `s` along, who is
-   * in `channel`.
+   * in `channel`. `facing` is 1 for a skater looking down the lake, -1 for one looking
+   * back up it, and between for one looking across.
    *
    * 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)
+  update(x: number, s: number, channel: Channel, facing = 1): void {
+    const first = Math.floor((s - (facing > 0.5 ? BEHIND : AHEAD)) / CHUNK)
+    const last = Math.floor((s + (facing < -0.5 ? BEHIND : AHEAD)) / CHUNK)
     for (const [index, chunk] of this.chunks) {
       if (index >= first && index <= last) continue
       this.drop(chunk)
       this.chunks.delete(index)
     }
-    for (let index = first; index <= last; index++) {
-      if (!this.chunks.has(index)) this.chunks.set(index, this.make(index, channel))
+    // Nearest first, and only so many in a frame; the rest are for the frames after.
+    const here = Math.floor(s / CHUNK)
+    let made = 0
+    for (let away = 0; made < MADE_AT_ONCE && (here - away >= first || here + away <= last); away++) {
+      for (const index of away === 0 ? [here] : [here + away, here - away]) {
+        if (index < first || index > last || this.chunks.has(index) || made >= MADE_AT_ONCE) continue
+        this.chunks.set(index, this.make(index, channel))
+        // The very first frame makes everything: there is nothing yet to look at meanwhile.
+        if (this.chunks.size > 1 && !this.starting) made++
+      }
     }
+    this.starting = false
     for (const chunk of this.chunks.values()) {
       chunk.group.position.set(chunk.x - x, 0, -(chunk.s - s))
     }
@@ -199,13 +294,10 @@ export class Shores {
     }), 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)]
+      return [islandLeft + t * (islandRight - islandLeft), islandHeight(lake, channel, s, t)]
     }), snow))
-    group.add(trees(lake, channel, this.seed, index, x0, s0))
+    group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0))
     this.group.add(group)
     return { x: x0, s: s0, group }
   }
diff --git a/src/skater.ts b/src/skater.ts
index 76cee11..b881eef 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -34,11 +34,16 @@ export const MAX_TURN = 0.45
 /** Seconds for the turn to follow the steering: the weight of a body leaning over. */
 const TURN_LAG = 0.3
 /**
- * The furthest off the line of the channel the skater may point, radians: 50 degrees.
- * Nothing in the lake needs this, which is why it is a rule of its own: lift it and the
- * skater can turn right round, as the rest of this model already allows.
+ * The furthest towards a shore the skater points when they are at the edge of the room
+ * to do it in, radians: 50 degrees. In open water there is no such limit. A skater held
+ * hard over turns right round, and can circle an islet, as the skater in the film does.
  */
 export const MAX_HEADING = 0.87
+/** How close the skater may come to the edge of an islet, metres. */
+export const ISLET_MARGIN = 1.5
+/** How far ahead an islet in the way is steered round, metres, and by how much it is cleared. */
+const ISLET_AHEAD = 30
+const ISLET_BERTH = 3.5
 
 /** The autopilot never lets the skater closer to a shore than this, metres. */
 export const SHORE_MARGIN = 2
@@ -66,6 +71,8 @@ export const RETURN_TIME = 3
 const LOOK_AHEAD = 18
 /** The most the autopilot's line strays from the middle of the channel, metres. */
 const CARVE = 6
+/** From this far before a fork, metres, steering one way is asking to go that way at it. */
+const FORK_ASK = 200
 
 /** How long stride number `index` lasts, seconds: the same every time it is asked. */
 export function strideSeconds(index: number): number {
@@ -94,6 +101,11 @@ export interface Skater {
   hold: number
   /** Seconds since the skater was last steered. */
   idle: number
+  /**
+   * Which way the skater last steered while coming up to a fork, -1 or 1, and 0 if they
+   * have not: the way they have asked to go at it.
+   */
+  wish: number
   /** Strides taken, with the fraction being how far through the current one. */
   stride: number
   /** Seconds left in the water after falling through the ice; 0 while skating. */
@@ -105,7 +117,7 @@ export function createSkater(lake: Lake, s = 0): Skater {
   const channel = channelAt(lake, s)
   return {
     x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED,
-    hold: 0, idle: Infinity, stride: 0, under: 0,
+    hold: 0, idle: Infinity, wish: 0, stride: 0, under: 0,
   }
 }
 
@@ -125,14 +137,15 @@ function alongside(k: Skater): Channel | undefined {
 }
 
 /**
- * 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, the room starts
- * closing early, by no more than half a metre for each metre forward.
+ * How far from the middle of `channel` a skater at `s` may be, metres. It looks both ways
+ * along the lake, since a skater may be going either: where the lake narrows faster than
+ * a skater can be steered clear, the room starts closing early, by no more than half a
+ * metre for each metre towards the narrows.
  */
 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)
+  for (let off = -200; off <= 200; off += 20) {
+    room = Math.min(room, channel.halfWidth(s + off) - SHORE_MARGIN + 0.5 * Math.abs(off))
   }
   return room
 }
@@ -154,6 +167,16 @@ function room(k: Skater, s: number): [number, number] {
 }
 
 const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v))
+/**
+ * How far towards an edge of their room the skater may point when it is `room` metres
+ * off, radians: not at all at the edge itself, `MAX_HEADING` a little way from it, and
+ * straight at it from three times that far, which is as good as no limit.
+ */
+const mayPoint = (room: number): number =>
+  clamp(room / EDGE, 0, 1) * MAX_HEADING + clamp((room - EDGE) / (2 * EDGE), 0, 1) * (Math.PI / 2 - MAX_HEADING)
+
+/** An angle as the same angle between a half turn left and a half turn right. */
+const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI))
 
 /**
  * Move the skater on by `dt` seconds. `steer` is -1 (hard left) to 1 (hard right).
@@ -168,11 +191,18 @@ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.m
  * After being steered it first keeps to where it was put, then eases back to that line.
  *
  * Either way the skater is kept on the ice. Near the edge of their room they are turned
- * to run along it, and at the edge itself they are held there.
+ * to run along it, and at the edge itself they are held there. An islet in the way is
+ * steered round: early and gently by the autopilot, and at the last moment for a skater
+ * who is steering at it.
+ *
+ * The skater may point any way at all, back up the lake included. Nothing here assumes
+ * otherwise; left alone, the autopilot turns them down the lake again.
  *
- * 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.
+ * At a fork, a skater who has steered one way on the way up to it goes that way, and one
+ * who has not goes the way the fork prefers. A touch is enough: the steering is taken as
+ * asking, and the autopilot does the rest. Until the island between the two channels
+ * begins the skater can still change, by steering the other way or by simply being
+ * nearer the middle of the other.
  */
 export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rules = RULES): void {
   if (k.under > 0) {
@@ -195,8 +225,12 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   if (Math.abs(steer) > 0.05) {
     k.idle = 0
     wanted = clamp(steer, -1, 1) * MAX_TURN
+    // Coming up to a fork, or between its two channels, this is asking for a side of it.
+    const side = alongside(k)
+    if (k.s > channel.end.s - FORK_ASK) k.wish = Math.sign(steer)
+    else if (side && (steer < 0 ? channel.birth!.left : channel.birth!.right) === side) k.channel = side
     // Where this is taking the skater is where the autopilot will keep them afterwards.
-    k.hold = k.x + Math.sin(k.heading - along) * k.speed * 1 - channel.centre(k.s)
+    k.hold = k.x + Math.sin(k.heading - along) * k.speed - k.channel.centre(k.s)
   } else {
     k.idle += dt
     const settled = clamp((k.idle - IDLE_BEFORE_RETURN) / 5, 0, 1)
@@ -210,18 +244,45 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
     // Aimed clear of the edges, or down the middle where there is not the room for that.
     const clear = Math.min(EDGE, (farRight - farLeft) / 2)
     const aim = clamp(channel.centre(ahead) + k.hold + carve, farLeft + clear, farRight - clear)
-    wanted = clamp((Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN)
+    wanted = clamp(wrap(Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN)
   }
+
+  // An islet in the way. The turn that would just clear it is the one that moves the
+  // skater sideways by what they are short of, in the distance there is to do it in.
+  // The autopilot makes that turn as soon as it is one; a skater who is steering is left
+  // to it until the turn needed is a sharp one.
+  const ahead = [Math.sin(k.heading), Math.cos(k.heading)] as const
+  let sharpest = Math.abs(steer) > 0.05 ? 0.6 * MAX_TURN : 0
+  for (const islet of channel.islets(k.s - ISLET_AHEAD - 10, k.s + ISLET_AHEAD + 10)) {
+    const dx = islet.x - k.x
+    const ds = islet.s - k.s
+    const along = dx * ahead[0] + ds * ahead[1]
+    // How far to the right of the skater's line its middle is.
+    const aside = dx * ahead[1] - ds * ahead[0]
+    const short = islet.radius + ISLET_BERTH - Math.abs(aside)
+    if (along < 0 || along > ISLET_AHEAD + islet.radius || short <= 0) continue
+    const needed = Math.min(2 * MAX_TURN, (2.6 * short * k.speed) / Math.max(along - islet.radius, 2) ** 2)
+    if (needed > sharpest) {
+      sharpest = needed
+      // Round whichever side of it the skater is already on.
+      wanted = aside >= 0 ? Math.min(wanted, -needed) : Math.max(wanted, needed)
+    }
+  }
+
   // Near the edge of the ice the skater may point less and less towards it, down to
-  // not at all at the edge itself, and is turned back if they point further than that.
-  const out = k.heading - along
-  const mayRight = clamp((right - k.x) / EDGE, 0, 1) * MAX_HEADING
-  const mayLeft = clamp((k.x - left) / EDGE, 0, 1) * MAX_HEADING
-  if (out > mayRight) wanted = Math.min(wanted, clamp((mayRight - out) / 0.2, -2 * MAX_TURN, 0))
-  if (out < -mayLeft) wanted = Math.max(wanted, clamp((-mayLeft - out) / 0.2, 0, 2 * MAX_TURN))
+  // not at all at the edge itself (`mayPoint`), and is turned away if they point further:
+  // whichever way round is the shorter, which for a skater heading back up the lake is
+  // the other way.
+  const out = wrap(k.heading - along)
+  const towards = Math.sin(out)
+  const away = Math.cos(out) >= 0 ? 1 : -1
+  const mayRight = Math.sin(mayPoint(right - k.x))
+  const mayLeft = Math.sin(mayPoint(k.x - left))
+  if (towards > mayRight) wanted = -away * clamp((towards - mayRight) / 0.2, Math.max(0, -away * wanted), 2 * MAX_TURN)
+  if (towards < -mayLeft) wanted = away * clamp((-mayLeft - towards) / 0.2, Math.max(0, away * wanted), 2 * MAX_TURN)
 
   k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG))
-  k.heading = clamp(k.heading + k.turn * dt, along - MAX_HEADING, along + MAX_HEADING)
+  k.heading += k.turn * dt
 
   const pushing = sincePush(k) < PUSH_SECONDS
   k.speed += ((pushing ? PUSH : 0) - DRAG * (k.speed / SPEED) ** 2) * dt
@@ -231,7 +292,11 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   k.s += k.speed * Math.cos(k.heading) * dt
   k.x += k.speed * Math.sin(k.heading) * dt
   k.stride += dt / strideSeconds(Math.floor(k.stride))
-  if (k.s >= k.channel.end.s) k.channel = k.channel.end.preferred
+  if (k.s >= k.channel.end.s) {
+    const fork = k.channel.end
+    k.channel = k.wish < 0 ? fork.left : k.wish > 0 ? fork.right : fork.preferred
+    k.wish = 0
+  }
   // At the island's tip the matter is settled by which side of it the skater is on.
   const beside = alongside(k) ?? was
   const nearerBy = beside ? Math.abs(k.x - k.channel.centre(k.s)) - Math.abs(k.x - beside.centre(k.s)) : 0
@@ -244,9 +309,19 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   if (k.x < edgeLeft || k.x > edgeRight) {
     const over = k.x < edgeLeft
     k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -EDGE_PULL * dt, EDGE_PULL * dt)
-    const now = Math.atan(k.channel.slope(k.s))
-    const most = 2 * MAX_TURN * dt
-    k.heading += over ? clamp(now - k.heading, 0, most) : clamp(now - k.heading, -most, 0)
+    // Turned along it, the nearer way round, as fast as a skater can be turned.
+    const off = wrap(k.heading - Math.atan(k.channel.slope(k.s)))
+    const pointing = Math.sin(off) * (over ? -1 : 1)
+    if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * MAX_TURN * dt, Math.abs(off))
+  }
+  // And off the islets: a skater who has got inside the margin of one is moved out of it.
+  for (const islet of k.channel.islets(k.s - 20, k.s + 20)) {
+    const far = Math.hypot(k.x - islet.x, k.s - islet.s)
+    const inside = islet.radius + ISLET_MARGIN - far
+    if (inside <= 0 || far === 0) continue
+    const push = Math.min(inside, EDGE_PULL * dt) / far
+    k.x += (k.x - islet.x) * push
+    k.s += (k.s - islet.s) * push
   }
 
   if (rules.falling && lake.thickness(k.x, k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS
diff --git a/src/world/lake.ts b/src/world/lake.ts
index 0d90431..f815f36 100644
--- a/src/world/lake.ts
+++ b/src/world/lake.ts
@@ -41,12 +41,31 @@ export interface Channel {
   slope(s: number): number
   /** From the middle to either shore, metres. */
   halfWidth(s: number): number
+  /**
+   * The islets standing in the ice between `from` and `to` metres along, in the order
+   * they are come to.
+   */
+  islets(from: number, to: number): Islet[]
   /** 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
 }
 
+/**
+ * A tuft of reeds standing in the ice, on a hump of frozen ground: something to skate
+ * round. In the film this is modelled on they stand in clusters, and the skater weaves
+ * between them.
+ */
+export interface Islet {
+  /** Where its middle is, across the lake and along it, and how far its edge is from that, metres. */
+  x: number
+  s: number
+  radius: number
+  /** A number of its own, for whatever is drawn on it. */
+  seed: number
+}
+
 /** Where one channel becomes two. */
 export interface Fork {
   /** Where the channels part, and, further on, where the island between them begins. */
@@ -102,6 +121,17 @@ const CALM_FADE = 250
  */
 export const FORK_ZONE = 1200
 const CALM_AFTER = FORK_ZONE
+/**
+ * Islets come in beds: stretches of the lake some hundreds of metres long that have
+ * them, with long stretches between that have none. Within a bed there is room for one
+ * every `ISLET_EVERY` metres, and most of those have one. An islet is `ISLET_RADIUS`
+ * across its middle, keeps `ISLET_CLEAR` metres of ice between itself and a shore, and
+ * is not found where the water is narrower than `ISLET_WATER` to a side, nor near a fork.
+ */
+const ISLET_EVERY = 50
+const ISLET_RADIUS = [2.5, 7.5] as const
+const ISLET_CLEAR = 9
+const ISLET_WATER = 16
 /** 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
@@ -177,11 +207,34 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce:
     }
 
     const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(s))
+    /** The islet in the `index`th place there is room for one, if there is one in it. */
+    const islet = (index: number): Islet | undefined => {
+      const s = (index + 0.2 + 0.6 * hash(lane, 1000 + index)) * ISLET_EVERY
+      // None while the other channel of a fork is still in sight, nor coming up to one.
+      if (s < born + FORK_ZONE + 100 || s > ends - 300) return undefined
+      if (noise(s / 900, key + 300) < 0.25 || hash(lane, 2000 + index) > 0.7) return undefined
+      const width = self.halfWidth(s)
+      if (width < ISLET_WATER) return undefined
+      const radius = mix(ISLET_RADIUS[0], ISLET_RADIUS[1], hash(lane, 3000 + index) ** 2)
+      // Anywhere across the water that leaves ice to skate on both sides of it; out at
+      // sea, within sight of the middle.
+      const span = Math.min(width, 110) - radius - ISLET_CLEAR
+      return { x: centre(s) + (2 * hash(lane, 4000 + index) - 1) * span, s, radius, seed: (lane ^ Math.imul(index, 0x85ebca6b)) >>> 0 }
+    }
     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)),
+      islets: (from, to) => {
+        // Before the fork that made it, a channel is the one it forked from, islets and all.
+        const found = parent && from < born ? parent.islets(from, Math.min(to, born)) : []
+        for (let index = Math.floor(Math.max(from, born) / ISLET_EVERY); index * ISLET_EVERY < to; index++) {
+          const here = islet(index)
+          if (here && here.s >= from && here.s < to) found.push(here)
+        }
+        return found
+      },
       birth,
       get end() {
         return (end ??= fork(self, lane, shift, ends))
diff --git a/test/lake.test.ts b/test/lake.test.ts
index 28d4c88..f99f70d 100644
--- a/test/lake.test.ts
+++ b/test/lake.test.ts
@@ -180,6 +180,37 @@ describe('lake', () => {
     expect(changing.thickness(...found!)).toBe(BREAKING_THICKNESS)
   })
 
+  it('has islets in beds, with ice to skate on all round each, and none near a fork', () => {
+    let count = 0
+    let stretches = 0
+    let withIslets = 0
+    follow(300_000, 500, (channel, s) => {
+      const found = channel.islets(s, s + 500)
+      stretches++
+      if (found.length) withIslets++
+      count += found.length
+      for (const islet of found) {
+        expect(islet.s).toBeGreaterThanOrEqual(s)
+        expect(islet.s).toBeLessThan(s + 500)
+        expect(islet.radius).toBeGreaterThanOrEqual(2.5)
+        expect(islet.radius).toBeLessThanOrEqual(7.5)
+        // Nine metres or more of ice between it and either shore.
+        const room = channel.halfWidth(islet.s) - Math.abs(islet.x - channel.centre(islet.s)) - islet.radius
+        expect(room).toBeGreaterThanOrEqual(9 - 1e-9)
+        // Not where a fork's two channels are both in sight, nor coming up to a fork.
+        if (channel.birth) expect(islet.s).toBeGreaterThan(channel.birth.s + FORK_ZONE)
+        expect(islet.s).toBeLessThan(channel.end.s - 250)
+      }
+      // The same islets however they are asked for.
+      const again = [...channel.islets(s, s + 200), ...channel.islets(s + 200, s + 500)]
+      expect(again.map((i) => i.s)).toEqual(found.map((i) => i.s))
+    })
+    console.info(`${count} islets in 300 km, in ${((100 * withIslets) / stretches).toFixed(0)}% of its half-kilometres`)
+    // In beds: many of them, and most of the lake without any.
+    expect(count).toBeGreaterThan(200)
+    expect(withIslets / stretches).toBeLessThan(0.4)
+  })
+
   it('gives the autopilot a line that wanders slowly from side to side', () => {
     let leftmost = 0
     let rightmost = 0
diff --git a/test/skater.test.ts b/test/skater.test.ts
index 8a7cbef..254fb0e 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -1,10 +1,10 @@
 import { describe, expect, it } from 'vitest'
 import { BREAKING_THICKNESS } from '../src/ice/strength.js'
 import {
-  IDLE_BEFORE_RETURN, MAX_HEADING, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS,
+  IDLE_BEFORE_RETURN, ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS,
   type Skater, createSkater, stepSkater, strideSeconds,
 } from '../src/skater.js'
-import { type Lake, createLake } from '../src/world/lake.js'
+import { type Lake, createLake, mulberry32 } from '../src/world/lake.js'
 
 const DT = 1 / 60
 const lake = createLake(1)
@@ -17,8 +17,27 @@ function run(k: Skater, steer: number, seconds: number, on = straight): void {
 }
 /** How far the skater is from the middle of their channel, metres; positive is right. */
 const off = (k: Skater): number => k.x - k.channel.centre(k.s)
-/** How far off the line of their channel they point, radians. */
-const out = (k: Skater): number => k.heading - Math.atan(k.channel.slope(k.s))
+/** How far off the line of their channel they point, radians, between a half turn either way. */
+const out = (k: Skater): number => {
+  const angle = k.heading - Math.atan(k.channel.slope(k.s))
+  return angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI))
+}
+/** How much ice there is between the skater and the nearest islet's edge, metres. */
+const clearOfIslets = (k: Skater): number =>
+  Math.min(Infinity, ...k.channel.islets(k.s - 30, k.s + 30).map((i) => Math.hypot(k.x - i.x, k.s - i.s) - i.radius))
+/** Someone steering this way and that, and sometimes not at all: the same someone every time. */
+function fidget(seed: number): (t: number) => number {
+  const random = mulberry32(seed)
+  let until = 0
+  let steer = 0
+  return (t) => {
+    if (t >= until) {
+      until = t + 0.5 + 4 * random()
+      steer = random() < 0.3 ? 0 : random() * 2 - 1
+    }
+    return steer
+  }
+}
 /** Whether there is nothing but the skater's own channel to either side of them. */
 const alone = (k: Skater): boolean => !k.channel.birth || k.s > k.channel.birth.tip
 // Channels are compared with `===`, never handed to `expect`: a channel leads to its fork
@@ -41,6 +60,8 @@ describe('skater', () => {
       expect(k.under).toBe(0)
       // On the ice, always: never on the shore side of the two metres kept clear of it.
       if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(k.channel.halfWidth(k.s) - 0.5)
+      // Round every islet, with room to spare.
+      expect(clearOfIslets(k)).toBeGreaterThan(ISLET_MARGIN)
       if (k.channel !== channel) {
         // Doing nothing takes the way the fork prefers.
         expect(k.channel === channel.end.preferred).toBe(true)
@@ -61,18 +82,17 @@ describe('skater', () => {
     expect(fastest - slowest).toBeGreaterThan(1)
   })
 
-  it('moves smoothly: no jumps, no jerks, and never turned further than it may be', () => {
-    for (const steer of [0, 1, -1]) {
+  it('moves smoothly, however it is steered: no jumps, no jerks', () => {
+    for (const seed of [1, 2, 3]) {
       const k = createSkater(lake)
-      for (let t = 0; t < 900; t += DT) {
-        const { x, heading, speed } = k
-        // Steered hard one way, with a rest every so often.
-        stepSkater(k, lake, Math.floor(t / 7) % 3 === 0 ? 0 : steer, DT)
-        expect(Math.abs(k.x - x)).toBeLessThan(0.25)
+      const steer = fidget(seed)
+      for (let t = 0; t < 600; t += DT) {
+        const { x, s, heading, speed } = k
+        stepSkater(k, lake, steer(t), DT)
+        expect(Math.hypot(k.x - x, k.s - s)).toBeLessThan(0.25)
         expect(Math.abs(k.heading - heading)).toBeLessThan(0.05)
         expect(Math.abs(k.speed - speed)).toBeLessThan(0.05)
         expect(Math.abs(k.turn)).toBeLessThanOrEqual(2 * MAX_TURN)
-        expect(Math.abs(out(k))).toBeLessThanOrEqual(MAX_HEADING + 0.02)
       }
     }
   })
@@ -91,23 +111,56 @@ describe('skater', () => {
     expect(out(k)).toBeLessThan(0)
   })
 
-  it('cannot be steered into a shore, however wide or narrow the lake', () => {
-    for (const steer of [-1, 1]) {
+  it('cannot be steered into a shore or onto an islet, however wide or narrow the lake', () => {
+    for (const seed of [4, 5, 6]) {
       const k = createSkater(lake)
-      for (let t = 0; t < 1800; t += DT) {
-        stepSkater(k, straight, steer, DT)
+      const steer = fidget(seed)
+      let nearest = Infinity
+      for (let t = 0; t < 1200; t += DT) {
+        stepSkater(k, straight, steer(t), DT)
         if (alone(k)) {
           expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.5)
         }
+        nearest = Math.min(nearest, clearOfIslets(k))
+        // Never on one. (Steered straight at one they come inside its margin for a moment.)
+        expect(nearest).toBeGreaterThan(0.2)
       }
+      // And they did get in among the islets: this was not passed by keeping away.
+      expect(nearest).toBeLessThan(15)
+    }
+  })
+
+  it('held hard over, turns right round, and left alone turns back down the lake', () => {
+    // Out at sea, where there is the room to.
+    const k = createSkater(lake, 3400)
+    expect(k.channel.halfWidth(k.s)).toBeGreaterThan(100)
+    let furthestBack = 0
+    const start = k.heading
+    for (let t = 0; t < (2 * Math.PI) / MAX_TURN + 3; t += DT) {
+      const before = k.s
+      stepSkater(k, straight, 1, DT)
+      furthestBack = Math.min(furthestBack, k.s - before)
     }
+    // A whole circle, part of it back up the lake.
+    expect(k.heading - start).toBeGreaterThan(2 * Math.PI * 0.9)
+    expect(furthestBack).toBeLessThan(-0.05)
+    // Let go part way round the next one, facing back the way they came.
+    for (let i = 0; i < 1200 && Math.cos(out(k)) > -0.9; i++) stepSkater(k, straight, 1, DT)
+    expect(Math.cos(out(k))).toBeLessThan(-0.85)
+    run(k, 0, 25)
+    expect(Math.abs(out(k))).toBeLessThan(0.1)
+    const then = k.s
+    run(k, 0, 5)
+    expect(k.s - then).toBeGreaterThan(SPEED * 5 * 0.8)
   })
 
   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, straight, steer, DT)
+      // A nudge that way and no more: held over, it would be a circle.
+      run(k, steer, 1.2)
+      while (k.s < fork.tip + 50) stepSkater(k, straight, 0, DT)
       expect(k.channel === wanted).toBe(true)
       // And from there on left alone, stays in it, and comes back to its middle.
       run(k, 0, 60)
@@ -116,18 +169,19 @@ describe('skater', () => {
     }
   })
 
-  it('can change its mind at a fork until the island begins, and not after', () => {
+  it('can change its mind at a fork until the island begins, and then runs along its shore', () => {
     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 === fork.preferred).toBe(true)
-    while (k.channel !== other) stepSkater(k, straight, 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, straight, towards, DT)
-    run(k, -towards, 30)
+    run(k, towards, 1.2)
+    while (k.s < fork.tip + 5) stepSkater(k, straight, 0, DT)
+    expect(k.channel === other).toBe(true)
+    // Past the island's tip, a nudge back towards it does not cross it.
+    run(k, -towards, 1.5)
+    run(k, 0, 20)
     expect(k.channel === other).toBe(true)
   })