fuzzyworld

nordic-skating / commit

feat: an endless lake, skated on autopilot, with steering

8b9467caeda280f75ef38438dedf87ae0dc5918b · Pavel Safronov · 2026-10-08 23:21:10 -0700 · parents 6d112625b1d1

Claude Code usage: this commit ~65.6K out / ~4.6M cached · cost unknown (no per-model trailer) · session 1 tally 206 in / 110.0K out / 7.1M cached · usage by commit and session

feat: an endless lake, skated on autopilot, with steering

The lake is a function of the distance skated and a seed: where its
middle is, how wide it is, how the land rises from its shores. Any
stretch is made on demand and nothing is stored.

The skater is held as an offset from the middle of the channel, so an
offset nobody touches follows every bend, and that is the autopilot.
A drag, the arrow keys or A and D move the offset; a shore cannot be
reached; let go and the line is held for 1.5 s and then eases back.

The skater never leaves the origin of what is drawn. Shores and their
spruce are built 100 m at a time ahead and thrown away behind, and the
ice is one sheet whose texture of cracks and frost slides the other way,
so the picture is as sharp 10,000 km down the lake as at the start.

The ice is matt: glossy, it mirrored the sun as one glaring streak.

Usage: 206 in / 110.0K out / 7.1M cached (session tally)
rtk:   1.8M saved, 25.6% over 2806 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 index.html           |   1 +
 package-lock.json    |   7 +++
 package.json         |   1 +
 src/input.ts         |  44 ++++++++++++++++
 src/main.ts          |  74 +++++++++++++++++++++------
 src/render/ice.ts    | 117 +++++++++++++++++++++++++++++++++++++++++++
 src/render/shores.ts | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/skater.ts        |  67 +++++++++++++++++++++++++
 src/style.css        |  20 ++++++++
 src/world/lake.ts    |  54 ++++++++++++++++++++
 test/lake.test.ts    |  42 ++++++++++++++++
 test/skater.test.ts  |  66 ++++++++++++++++++++++++
 12 files changed, 615 insertions(+), 16 deletions(-)

diff --git a/index.html b/index.html
index 378fe13..6804e0e 100644
--- a/index.html
+++ b/index.html
@@ -8,6 +8,7 @@
 </head>
 <body>
 <canvas id="scene"></canvas>
+<p id="hint">Drag left or right to steer — or just watch</p>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </html>
diff --git a/package-lock.json b/package-lock.json
index 5ad412c..436de3d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
       "name": "nordic-skating",
       "version": "0.1.0",
       "dependencies": {
+        "simplex-noise": "^4.0.3",
         "three": "^0.180.0"
       },
       "devDependencies": {
@@ -1274,6 +1275,12 @@
         "@rolldown/binding-win32-x64-msvc": "1.2.13"
       }
     },
+    "node_modules/simplex-noise": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
+      "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
+      "license": "MIT"
+    },
     "node_modules/source-map-js": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
diff --git a/package.json b/package.json
index 2079033..c0644e4 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
     "test:watch": "vitest"
   },
   "dependencies": {
+    "simplex-noise": "^4.0.3",
     "three": "^0.180.0"
   },
   "devDependencies": {
diff --git a/src/input.ts b/src/input.ts
new file mode 100644
index 0000000..fca27bb
--- /dev/null
+++ b/src/input.ts
@@ -0,0 +1,44 @@
+/** How far a finger has to be dragged sideways for full steering, CSS pixels. */
+const FULL_DRAG = 70
+
+/**
+ * Steering, from whatever is to hand: a finger or mouse dragged sideways from where it
+ * went down, or the arrow keys or A and D.
+ */
+export class Input {
+  private pointer: number | null = null
+  private startX = 0
+  private drag = 0
+  private readonly keys = new Set<string>()
+
+  constructor(target: HTMLElement) {
+    target.addEventListener('pointerdown', (e) => {
+      if (this.pointer !== null) return
+      this.pointer = e.pointerId
+      this.startX = e.clientX
+      this.drag = 0
+      target.setPointerCapture(e.pointerId)
+    })
+    target.addEventListener('pointermove', (e) => {
+      if (e.pointerId === this.pointer) this.drag = (e.clientX - this.startX) / FULL_DRAG
+    })
+    const release = (e: PointerEvent): void => {
+      if (e.pointerId !== this.pointer) return
+      this.pointer = null
+      this.drag = 0
+    }
+    target.addEventListener('pointerup', release)
+    target.addEventListener('pointercancel', release)
+    window.addEventListener('keydown', (e) => this.keys.add(e.code))
+    window.addEventListener('keyup', (e) => this.keys.delete(e.code))
+    // A key held while the window loses focus never sends its keyup.
+    window.addEventListener('blur', () => this.keys.clear())
+  }
+
+  /** -1 (hard left) to 1 (hard right); 0 when nothing is being pressed. */
+  steer(): number {
+    const held = (...codes: string[]): number => (codes.some((c) => this.keys.has(c)) ? 1 : 0)
+    const keys = held('ArrowRight', 'KeyD') - held('ArrowLeft', 'KeyA')
+    return Math.max(-1, Math.min(1, keys + this.drag))
+  }
+}
diff --git a/src/main.ts b/src/main.ts
index cbff3e9..8f21a8e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,46 +1,88 @@
 import './style.css'
 import * as THREE from 'three'
+import { Input } from './input.js'
+import { Ice } from './render/ice.js'
+import { Shores } from './render/shores.js'
+import { createSkater, skaterX, stepSkater } from './skater.js'
+import { createLake } from './world/lake.js'
 
 const SKY = 0x9db4c8
-// Black ice: clear enough that the dark water under it is what you see.
-const ICE = 0x0b1a24
 // Eye height of a skater leaning into a stride, in metres.
 const EYE = 1.5
+const SEED = 1
 
 const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
+const hint = document.querySelector<HTMLElement>('#hint')!
 const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
 // Phones report a ratio of 3 or more, and the fill cost grows with its square.
 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
 
 const scene = new THREE.Scene()
 scene.background = new THREE.Color(SKY)
-// The far edge of the ice fades into the sky, so the lake has no visible end.
+// The lake and its shores fade into the sky, so neither has a visible end.
 scene.fog = new THREE.Fog(SKY, 30, 600)
 
+// The skater is always at the origin of what is drawn, looking down -z; the ice and the
+// shores are moved past instead (render/ice.ts, render/shores.ts).
 const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500)
-camera.position.set(0, EYE, 0)
-camera.lookAt(0, EYE * 0.6, -30)
-
-const ice = new THREE.Mesh(
-  new THREE.PlaneGeometry(3000, 3000),
-  new THREE.MeshStandardMaterial({ color: ICE, roughness: 0.12 }),
-)
-ice.rotation.x = -Math.PI / 2
-scene.add(ice)
-
-scene.add(new THREE.HemisphereLight(SKY, ICE, 1.5))
-// A low winter sun ahead and to the left, which is what puts a sheen on the ice.
+camera.rotation.order = 'YXZ'
+
+const lake = createLake(SEED)
+const ice = new Ice(renderer)
+const shores = new Shores(lake, SEED)
+scene.add(ice.mesh, shores.group)
+
+scene.add(new THREE.HemisphereLight(SKY, 0x0b1a24, 1.5))
+// A low winter sun ahead and to the left, which is what gives the banks and trees a lit side.
 const sun = new THREE.DirectionalLight(0xfff1d6, 3)
 sun.position.set(-40, 12, -100)
 scene.add(sun)
 
+/** How far below level the skater looks, radians. */
+let pitch = 0
+
 function resize(): void {
   const { clientWidth: w, clientHeight: h } = canvas
   renderer.setSize(w, h, false)
   camera.aspect = w / h
+  // The field of view is the vertical one. A phone held upright needs a wide one, or the
+  // view across is so narrow that both shores fall outside it.
+  camera.fov = w < h ? 90 : 65
+  // And held upright there is far more height than the lake needs: look further down, at
+  // the ice coming, rather than fill the top half of the screen with sky.
+  pitch = w < h ? 0.3 : 0.12
   camera.updateProjectionMatrix()
 }
 window.addEventListener('resize', resize)
 resize()
 
-renderer.setAnimationLoop(() => renderer.render(scene, camera))
+// `?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 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.
+setTimeout(() => hint.classList.add('gone'), 8000)
+
+renderer.setAnimationLoop(() => {
+  // A tab left in the background comes back with one enormous step; skate it as a short one.
+  const dt = Math.min(clock.getDelta(), 0.1)
+  const steer = input.steer()
+  if (steer !== 0) hint.classList.add('gone')
+  stepSkater(skater, lake, steer, dt)
+
+  // 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
+  camera.position.y = EYE - 0.04 * Math.abs(sway)
+  // 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 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)
+  renderer.render(scene, camera)
+})
diff --git a/src/render/ice.ts b/src/render/ice.ts
new file mode 100644
index 0000000..7df525a
--- /dev/null
+++ b/src/render/ice.ts
@@ -0,0 +1,117 @@
+import * as THREE from 'three'
+import { mulberry32 } from '../world/lake.js'
+
+// Black ice: clear enough that the dark water under it is what you see.
+const ICE = '#0b1a24'
+/** Side of the square of ice the texture covers before it repeats, metres. */
+const TILE = 32
+const PIXELS = 2048
+/** The marks are sized in metres; this is how many pixels a metre is. */
+const PER_METRE = PIXELS / TILE
+/** Side of the sheet of ice that is drawn, metres: well past where the fog ends. */
+const SIZE = 3200
+
+/** Draw `paint` nine times, shifted by a tile each way, so that it wraps at every edge. */
+function wrapped(g: CanvasRenderingContext2D, paint: () => void): void {
+  for (const dx of [-PIXELS, 0, PIXELS]) {
+    for (const dy of [-PIXELS, 0, PIXELS]) {
+      g.save()
+      g.translate(dx, dy)
+      paint()
+      g.restore()
+    }
+  }
+}
+
+/**
+ * The marks that show the ice is moving under you: without them a sheet of black ice is
+ * one flat colour and the skater appears to stand still. Frost in soft patches, the white
+ * hairline cracks that black ice is laced with, and bubbles frozen into it.
+ */
+function paintIce(): HTMLCanvasElement {
+  const canvas = document.createElement('canvas')
+  canvas.width = canvas.height = PIXELS
+  const g = canvas.getContext('2d')!
+  const random = mulberry32(2018)
+  g.fillStyle = ICE
+  g.fillRect(0, 0, PIXELS, PIXELS)
+
+  for (let i = 0; i < 45; i++) {
+    const x = random() * PIXELS
+    const y = random() * PIXELS
+    const r = (1.2 + random() * 5.5) * PER_METRE
+    const alpha = 0.04 + random() * 0.1
+    wrapped(g, () => {
+      const frost = g.createRadialGradient(x, y, 0, x, y, r)
+      frost.addColorStop(0, `rgba(150, 180, 200, ${alpha})`)
+      frost.addColorStop(1, 'rgba(150, 180, 200, 0)')
+      g.fillStyle = frost
+      g.fillRect(x - r, y - r, 2 * r, 2 * r)
+    })
+  }
+
+  g.lineCap = 'round'
+  g.lineJoin = 'round'
+  for (let i = 0; i < 30; i++) {
+    // A crack runs roughly straight, kinking a little at each grain of the ice.
+    const points: [number, number][] = [[random() * PIXELS, random() * PIXELS]]
+    let direction = random() * Math.PI * 2
+    const kinks = 5 + Math.floor(random() * 10)
+    for (let k = 0; k < kinks; k++) {
+      direction += (random() - 0.5) * 0.9
+      const step = (1 + random() * 2.2) * PER_METRE
+      const [x, y] = points[points.length - 1]!
+      points.push([x + Math.cos(direction) * step, y + Math.sin(direction) * step])
+    }
+    g.strokeStyle = `rgba(205, 228, 240, ${0.3 + random() * 0.5})`
+    g.lineWidth = 1.2 + random() * 1.6
+    wrapped(g, () => {
+      g.beginPath()
+      points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x, y) : g.lineTo(x, y)))
+      g.stroke()
+    })
+  }
+
+  g.fillStyle = 'rgba(200, 225, 240, 0.22)'
+  for (let i = 0; i < 900; i++) {
+    g.beginPath()
+    g.arc(random() * PIXELS, random() * PIXELS, 0.8 + random() * 2, 0, Math.PI * 2)
+    g.fill()
+  }
+  return canvas
+}
+
+/**
+ * The ice is one sheet that stays under the skater while its texture slides the other
+ * way. Moving the picture rather than the sheet is what lets the lake go on for ever:
+ * nothing drawn is ever far from the origin, so nothing loses precision however far the
+ * skater has gone.
+ */
+export class Ice {
+  readonly mesh: THREE.Mesh
+  private readonly texture: THREE.CanvasTexture
+
+  constructor(renderer: THREE.WebGLRenderer) {
+    this.texture = new THREE.CanvasTexture(paintIce())
+    this.texture.colorSpace = THREE.SRGBColorSpace
+    this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping
+    this.texture.repeat.set(SIZE / TILE, SIZE / TILE)
+    // The ice is seen at a grazing angle all the way to the horizon; without this the
+    // cracks smear into grey a few metres ahead.
+    this.texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
+    this.mesh = new THREE.Mesh(
+      new THREE.PlaneGeometry(SIZE, SIZE),
+      // Matt on purpose: a glossy sheet mirrors the sun as one glaring streak down the lake.
+      new THREE.MeshLambertMaterial({ map: this.texture }),
+    )
+    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 {
+    // 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)
+  }
+}
diff --git a/src/render/shores.ts b/src/render/shores.ts
new file mode 100644
index 0000000..3817a98
--- /dev/null
+++ b/src/render/shores.ts
@@ -0,0 +1,138 @@
+import * as THREE from 'three'
+import { type Lake, 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. */
+const INLAND = [0, 2, 6, 14, 30, 60, 120, 220, 400]
+/** 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 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)
+
+interface Chunk {
+  /** Where the chunk's own origin is: across the lake, and along it. */
+  x: number
+  s: number
+  group: THREE.Group
+}
+
+/**
+ * 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.
+ */
+function land(lake: Lake, side: -1 | 1, x0: number, s0: number): THREE.BufferGeometry {
+  const cols = INLAND.length
+  // 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)
+  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)
+      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
+        const b = a + 1
+        const c = a + cols
+        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)
+      }
+    }
+    return index
+  }
+  const geometry = new THREE.BufferGeometry()
+  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+  geometry.setIndex(quads(0, ROWS + 2))
+  geometry.computeVertexNormals()
+  geometry.setIndex(quads(1, ROWS + 1))
+  return geometry
+}
+
+function trees(lake: Lake, 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 at = new THREE.Object3D()
+  let n = 0
+  for (const side of [-1, 1] as const) {
+    for (let i = 0; i < TREES_PER_SIDE; i++) {
+      const s = s0 + random() * CHUNK
+      // 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)
+    }
+  }
+  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.
+ */
+export class Shores {
+  readonly group = new THREE.Group()
+  private readonly chunks = new Map<number, Chunk>()
+
+  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 {
+    const first = Math.floor((s - BEHIND) / CHUNK)
+    const last = Math.floor((s + AHEAD) / CHUNK)
+    for (const [index, chunk] of this.chunks) {
+      if (index >= first && index <= last) continue
+      this.group.remove(chunk.group)
+      for (const child of chunk.group.children) {
+        // The land is this stretch's own; the trees share one cone and one material.
+        if (child instanceof THREE.InstancedMesh) child.dispose()
+        else (child as THREE.Mesh).geometry.dispose()
+      }
+      this.chunks.delete(index)
+    }
+    for (let index = first; index <= last; index++) {
+      if (!this.chunks.has(index)) this.chunks.set(index, this.make(index))
+    }
+    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)
+    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))
+    this.group.add(group)
+    return { x, s, group }
+  }
+}
diff --git a/src/skater.ts b/src/skater.ts
new file mode 100644
index 0000000..91e1b84
--- /dev/null
+++ b/src/skater.ts
@@ -0,0 +1,67 @@
+import type { Lake } from './world/lake.js'
+
+/** Cruising speed, m/s: about 25 km/h, an easy pace on good ice. */
+export const SPEED = 7
+/** How fast full steering carries the skater across the channel, m/s. */
+export const LATERAL_SPEED = 4
+/** The autopilot never lets the skater closer to a shore than this, metres. */
+export const SHORE_MARGIN = 2
+/** 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. */
+export const RETURN_TIME = 3
+/** Strides per second: one push of one leg. */
+export const STRIDE_HZ = 0.75
+
+export interface Skater {
+  /** Distance skated along the lake, metres. */
+  s: number
+  /** Sideways distance from the middle of the channel, metres; positive is right. */
+  offset: number
+  /** Sideways speed across the channel, m/s. */
+  lateral: number
+  /** Direction of travel relative to straight ahead, radians; positive is right. */
+  heading: number
+  /** Seconds since the skater was last steered. */
+  idle: number
+  /** Strides taken, with the fraction being how far through the current one. */
+  stride: number
+}
+
+export function createSkater(s = 0): Skater {
+  return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0 }
+}
+
+/** 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
+}
+
+/**
+ * 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
+ * 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.
+ */
+export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): void {
+  const before = k.offset
+  if (Math.abs(steer) > 0.05) {
+    k.idle = 0
+    k.offset += Math.max(-1, Math.min(1, steer)) * LATERAL_SPEED * dt
+  } else {
+    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))
+  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
+  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 += STRIDE_HZ * dt
+}
diff --git a/src/style.css b/src/style.css
index cf4f36e..8eab72a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -14,3 +14,23 @@ body {
   width: 100vw;
   height: 100dvh;
 }
+
+#hint {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: calc(2rem + env(safe-area-inset-bottom));
+  margin: 0;
+  padding: 0 1rem;
+  text-align: center;
+  font: 500 1rem/1.4 system-ui, sans-serif;
+  color: #fff;
+  text-shadow: 0 1px 6px rgb(0 0 0 / 70%);
+  /* The canvas under it takes the drag. */
+  pointer-events: none;
+  transition: opacity 1.5s;
+}
+
+#hint.gone {
+  opacity: 0;
+}
diff --git a/src/world/lake.ts b/src/world/lake.ts
new file mode 100644
index 0000000..1dcf51f
--- /dev/null
+++ b/src/world/lake.ts
@@ -0,0 +1,54 @@
+import { createNoise2D } from 'simplex-noise'
+
+/** A small seeded generator: the same seed is the same lake, on every device. */
+export function mulberry32(seed: number): () => number {
+  let a = seed >>> 0
+  return () => {
+    a = (a + 0x6d2b79f5) >>> 0
+    let t = a
+    t = Math.imul(t ^ (t >>> 15), t | 1)
+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+  }
+}
+
+/**
+ * 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.
+ */
+export interface Lake {
+  /** 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. */
+  bankHeight(side: -1 | 1, s: number, d: number): 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
+
+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
+  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)),
+    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))
+    },
+  }
+}
diff --git a/test/lake.test.ts b/test/lake.test.ts
new file mode 100644
index 0000000..36ca5c6
--- /dev/null
+++ b/test/lake.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { MAX_HALF_WIDTH, MIN_HALF_WIDTH, createLake } from '../src/world/lake.js'
+
+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)
+    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(a.centre(123.4)).not.toBe(c.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)
+      expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH)
+    }
+  })
+
+  it('never bends too sharply to skate at full speed', () => {
+    const lake = createLake(1)
+    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)
+  })
+
+  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)
+        expect(lake.bankHeight(side, s, 60)).toBeGreaterThan(0)
+      }
+    }
+  })
+})
diff --git a/test/skater.test.ts b/test/skater.test.ts
new file mode 100644
index 0000000..49d21a7
--- /dev/null
+++ b/test/skater.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import {
+  IDLE_BEFORE_RETURN, LATERAL_SPEED, SHORE_MARGIN, SPEED,
+  createSkater, skaterX, stepSkater,
+} from '../src/skater.js'
+import { createLake } from '../src/world/lake.js'
+
+const DT = 1 / 60
+const lake = createLake(1)
+
+function run(k: ReturnType<typeof createSkater>, steer: number, seconds: number): void {
+  const steps = Math.round(seconds / DT)
+  for (let i = 0; i < steps; i++) stepSkater(k, lake, steer, DT)
+}
+
+describe('skater', () => {
+  it('left alone, follows the middle of the channel for as long as you like', () => {
+    const k = createSkater()
+    for (let t = 0; t < 3600; t += DT) {
+      const before = k.s
+      stepSkater(k, lake, 0, DT)
+      expect(k.s).toBeGreaterThan(before)
+      expect(k.offset).toBe(0)
+    }
+    expect(skaterX(k, lake)).toBe(lake.centre(k.s))
+    // 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()
+    run(k, 1, 1)
+    expect(k.offset).toBeCloseTo(LATERAL_SPEED, 1)
+    expect(k.heading).toBeGreaterThan(0)
+    run(k, -1, 2)
+    expect(k.offset).toBeCloseTo(-LATERAL_SPEED, 1)
+  })
+
+  it('cannot be steered into a shore', () => {
+    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)
+      }
+      expect(Math.sign(k.offset)).toBe(steer)
+    }
+  })
+
+  it('holds its line for a moment after steering, then eases back to the middle', () => {
+    const k = createSkater()
+    run(k, 1, 1)
+    const steered = k.offset
+    run(k, 0, IDLE_BEFORE_RETURN - 0.1)
+    expect(k.offset).toBeCloseTo(steered, 5)
+    run(k, 0, 30)
+    expect(Math.abs(k.offset)).toBeLessThan(0.01)
+  })
+
+  it('skates the same far down the lake as at its start', () => {
+    const k = createSkater(1e7)
+    run(k, 0, 10)
+    expect(k.s - 1e7).toBeGreaterThan(SPEED * 10 * 0.9)
+  })
+})