fuzzyworld

nordic-skating / commit

feat: thin ice is black, cracks under you, and can be heard and seen giving way

646446cd789803bda6ebc32cc89126d6d07d4afe · Pavel Safronov · 2026-10-09 07:27:45 -0700 · parents 6216d5cb3480

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 1 tally 344 in / 250.6K out / 17.0M cached · usage by commit and session

feat: thin ice is black, cracks under you, and can be heard and seen giving way

How thick the ice is, is how it looks: a map of the thickness around
the skater is laid on the dark sheet as added light, none where the ice
will not hold and more the thicker it gets, as black ice is black
because it is thin and clear.

On ice thin enough to crack, the cracks come from a few metres off and
faster the thinner it is, up to several a second at the point of giving
way. Going through is four cracks and the water, the view dropping to
it, and a dark screen until the skater is out.

Screenshots of thin ice and of a fall, and the README's account of the
zones.

Usage: 344 in / 250.6K out / 17.0M cached (session tally)
rtk:   1.8M saved, 25.6% over 2821 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                      |  36 ++++++++++++++++++++----
 docs/screenshots/autopilot.png | Bin 322125 -> 187810 bytes
 docs/screenshots/far.png       | Bin 273410 -> 227583 bytes
 docs/screenshots/fell.png      | Bin 0 -> 13898 bytes
 docs/screenshots/landscape.png | Bin 441507 -> 319389 bytes
 docs/screenshots/steering.png  | Bin 377348 -> 314805 bytes
 docs/screenshots/thin-ice.png  | Bin 0 -> 289903 bytes
 index.html                     |   1 +
 src/audio/iceSound.ts          |  31 ++++++++++++++++++++-
 src/main.ts                    |  29 +++++++++++++------
 src/render/ice.ts              |  62 +++++++++++++++++++++++++++++++++++++++--
 src/style.css                  |  20 +++++++++++++
 tools/screenshots.mjs          |  11 ++++++++
 13 files changed, 173 insertions(+), 17 deletions(-)

diff --git a/README.md b/README.md
index c9fc879..9874422 100644
--- a/README.md
+++ b/README.md
@@ -15,14 +15,14 @@ in which Mårten Ajne skates a small lake outside Stockholm.
 
 ## Status
 
-**Step 3 of 6: the sound.** 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. The thickness cannot be seen yet, and nothing happens on thin ice.
+**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.
 
 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.
+4. ✅ Ice zones that fall out of the physics, and falling through.
 5. Forks in the lake.
 6. Game controller, then multiplayer.
 
@@ -41,6 +41,26 @@ long as you leave it.
 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.
 
+## The ice
+
+How thick the ice is decides everything about it: how it looks, the note it sings, and
+whether it holds. The zones are not chosen. They are the three thresholds of Gold's
+formula for what a sheet of ice will carry (`src/ice/strength.ts`), worked out for an
+80 kg skater, and the note is the physics under [The sound](#the-sound).
+
+| Ice | Thickness | Looks | Sings at | What happens |
+|---|---|---|---|---|
+| Gives way | under 2.2 cm | black | above 1390 Hz | You go through. Four seconds later you skate on from the middle of the lake |
+| Cracks | 2.2 to 3.4 cm | nearly black | 900 to 1390 Hz | It holds, cracking around you: more often and nearer the thinner it gets |
+| 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.
+
+<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" />
+
 ## How the lake works
 
 - **The lake is a function, not a map** (`src/world/lake.ts`). Where its middle is, how
@@ -49,6 +69,8 @@ holds that line for a second and a half, then eases back to the middle.
   nothing is stored.
 - **The skater is an offset from the middle of the lake** (`src/skater.ts`), not a
   position. An offset nobody touches follows every bend, which is all the autopilot is.
+- **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
   every frame (`src/render/shores.ts`); the ice is one sheet whose texture slides the
@@ -103,7 +125,9 @@ against the film.
   of noise at its note, starting a little high and falling onto it, then dying away.
 - **The hum and the scrape**: the gliding blade keeps the note faintly alive and hisses.
 - **The cracks**: every few seconds the ice cracks somewhere between 40 and 600 m off,
-  and the chirp for that distance and that thickness arrives.
+  and the chirp for that distance and that thickness arrives. On ice thin enough to
+  crack under the skater they come from a few metres away, and faster the thinner it is.
+- **Going through**: four cracks in a fifth of a second, and the water.
 
 `npm run sound-check` renders twelve seconds of it without playing it and draws its
 spectrogram: six seconds on the film's 4.1 cm, then onto ice thinning to 2.8 cm, with
@@ -147,6 +171,8 @@ named `public`, and a `public/` already in the repository would collide with it.
 - The physics of the sound: Romeyn, R., Hanssen, A., Ruud, B. O., and Johansen, T. A.,
   [Sea ice thickness from air-coupled flexural waves](https://doi.org/10.5194/tc-15-2939-2021),
   *The Cryosphere* 15, 2939–2955, 2021 (CC BY 4.0).
+- What ice will carry: Gold, L. W., "Use of ice covers for transportation", *Canadian
+  Geotechnical Journal* 8, 170–181, 1971.
 - The National Geographic video above is the reference this is modelled on. It is
   copyrighted, and neither it nor any audio taken from it is part of this repository
   or the app: it is measured locally, and the app's sound is synthesized from those
diff --git a/docs/screenshots/autopilot.png b/docs/screenshots/autopilot.png
index 97617b9..e33572f 100644
Binary files a/docs/screenshots/autopilot.png and b/docs/screenshots/autopilot.png differ
diff --git a/docs/screenshots/far.png b/docs/screenshots/far.png
index bc1e374..45ea391 100644
Binary files a/docs/screenshots/far.png and b/docs/screenshots/far.png differ
diff --git a/docs/screenshots/fell.png b/docs/screenshots/fell.png
new file mode 100644
index 0000000..8287d36
Binary files /dev/null and b/docs/screenshots/fell.png differ
diff --git a/docs/screenshots/landscape.png b/docs/screenshots/landscape.png
index 8c67973..403eb55 100644
Binary files a/docs/screenshots/landscape.png and b/docs/screenshots/landscape.png differ
diff --git a/docs/screenshots/steering.png b/docs/screenshots/steering.png
index c07e38c..002dcf3 100644
Binary files a/docs/screenshots/steering.png and b/docs/screenshots/steering.png differ
diff --git a/docs/screenshots/thin-ice.png b/docs/screenshots/thin-ice.png
new file mode 100644
index 0000000..9ebf776
Binary files /dev/null and b/docs/screenshots/thin-ice.png differ
diff --git a/index.html b/index.html
index e6daa03..aa4a089 100644
--- a/index.html
+++ b/index.html
@@ -8,6 +8,7 @@
 </head>
 <body>
 <canvas id="scene"></canvas>
+<div id="water"><p>The ice gave way</p></div>
 <p id="hint">Tap for sound · drag left or right to steer — or just watch</p>
 <script type="module" src="/src/main.ts"></script>
 </body>
diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts
index ef80825..5cc9dc1 100644
--- a/src/audio/iceSound.ts
+++ b/src/audio/iceSound.ts
@@ -38,6 +38,7 @@ export class IceSound {
   private readonly humGain: GainNode
   private readonly scrapeGain: GainNode
   private readonly tickGain: GainNode
+  private readonly splashGain: GainNode
 
   constructor(private readonly ctx: BaseAudioContext, random: () => number = Math.random) {
     // A limiter, in effect: several rings and a crack can land together.
@@ -92,6 +93,13 @@ export class IceSound {
     this.tickGain = ctx.createGain()
     this.tickGain.gain.value = 0
     this.noise.connect(tick).connect(this.tickGain).connect(this.out)
+
+    const splash = ctx.createBiquadFilter()
+    splash.type = 'lowpass'
+    splash.frequency.value = 700
+    this.splashGain = ctx.createGain()
+    this.splashGain.gain.value = 0
+    this.noise.connect(splash).connect(this.splashGain).connect(this.out)
   }
 
   /**
@@ -104,6 +112,27 @@ export class IceSound {
     this.scrapeGain.gain.setTargetAtTime(0.012 + 0.03 * push, when, 0.03)
   }
 
+  /**
+   * The ice giving way under the skater: it breaks, the water takes them, and the blade
+   * is no longer on the ice to keep it sounding.
+   */
+  fall(h: number, when = this.ctx.currentTime): void {
+    for (const [after, distance] of [[0, 2], [0.04, 5], [0.11, 9], [0.2, 16]] as const) {
+      this.crack(h, distance, when + after, 0)
+    }
+    this.tickGain.gain.cancelScheduledValues(when)
+    this.tickGain.gain.setValueAtTime(0.5, when)
+    this.tickGain.gain.setTargetAtTime(0, when, 0.05)
+    this.splashGain.gain.cancelScheduledValues(when)
+    this.splashGain.gain.setValueAtTime(0, when)
+    this.splashGain.gain.linearRampToValueAtTime(1.6, when + 0.12)
+    this.splashGain.gain.setTargetAtTime(0, when + 0.12, 0.45)
+    this.humGain.gain.cancelScheduledValues(when)
+    this.humGain.gain.setTargetAtTime(0, when, 0.05)
+    this.scrapeGain.gain.cancelScheduledValues(when)
+    this.scrapeGain.gain.setTargetAtTime(0, when, 0.05)
+  }
+
   /** A skate striking ice `h` metres thick. `strength` is about 1. */
   strike(h: number, when = this.ctx.currentTime, strength = 1): void {
     const note = singingFrequency(h)
@@ -131,7 +160,7 @@ export class IceSound {
     const tone = this.ctx.createOscillator()
     tone.frequency.setValueCurveAtTime(frequencies, when, duration)
     // Sound spreading across a sheet thins as the circle it is on widens.
-    const loud = 0.5 * Math.sqrt(40 / distance)
+    const loud = Math.min(0.8, 0.5 * Math.sqrt(40 / distance))
     const gain = this.ctx.createGain()
     gain.gain.setValueAtTime(0, when)
     gain.gain.linearRampToValueAtTime(loud * 0.25, when + 0.003)
diff --git a/src/main.ts b/src/main.ts
index aed28e8..0ebe9f0 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,7 +4,8 @@ import { IceSound } from './audio/iceSound.js'
 import { Input } from './input.js'
 import { Ice } from './render/ice.js'
 import { Shores } from './render/shores.js'
-import { createSkater, skaterX, stepSkater, strideSeconds } from './skater.js'
+import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js'
+import { UNDER_SECONDS, createSkater, skaterX, stepSkater, strideSeconds } from './skater.js'
 import { createLake } from './world/lake.js'
 
 const SKY = 0x9db4c8
@@ -14,6 +15,7 @@ const SEED = 1
 
 const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
 const hint = document.querySelector<HTMLElement>('#hint')!
+const water = document.querySelector<HTMLElement>('#water')!
 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))
@@ -29,7 +31,7 @@ const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500)
 camera.rotation.order = 'YXZ'
 
 const lake = createLake(SEED)
-const ice = new Ice(renderer)
+const ice = new Ice(renderer, lake)
 const shores = new Shores(lake, SEED)
 scene.add(ice.mesh, shores.group)
 
@@ -89,19 +91,28 @@ renderer.setAnimationLoop(() => {
   const steer = input.steer()
   if (steer !== 0) hint.classList.add('gone')
   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 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.
+  water.classList.toggle('under', skater.under > 1)
 
-  if (sound) {
-    const h = lake.thickness(skaterX(skater, lake), skater.s)
+  if (sound && skater.under === 0) {
     // One strike of a skate at the start of each stride, no two quite alike.
     if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, 0.75 + 0.5 * Math.random())
     // The push is over half a second into a stride; the rest of it is glide.
     const sincePush = (skater.stride % 1) * strideSeconds(Math.floor(skater.stride))
     sound.glide(h, Math.exp(-sincePush / 0.5))
-    untilCrack -= dt
+    // Ice near its limit cracks under the skater: the thinner, the more often and the
+    // nearer, until at the breaking thickness it is cracking several times a second.
+    const strain = Math.max(0, Math.min(1, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS)))
+    untilCrack -= dt * (1 + 40 * strain ** 2)
     if (untilCrack <= 0) {
-      // Mostly near, now and then from far down the lake.
-      sound.crack(h, 40 + 560 * Math.random() ** 2, undefined, Math.random() * 2 - 1)
+      // On sound ice: mostly near, now and then from far down the lake.
+      const distance = strain > 0 ? 2 + 30 * (1 - strain) * Math.random() : 40 + 560 * Math.random() ** 2
+      sound.crack(h, distance, undefined, Math.random() * 2 - 1)
       untilCrack = 3 + 12 * Math.random()
     }
   }
@@ -110,7 +121,9 @@ renderer.setAnimationLoop(() => {
   // 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)
+  // 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))
diff --git a/src/render/ice.ts b/src/render/ice.ts
index 7df525a..6df8e4a 100644
--- a/src/render/ice.ts
+++ b/src/render/ice.ts
@@ -1,5 +1,6 @@
 import * as THREE from 'three'
-import { mulberry32 } from '../world/lake.js'
+import { BREAKING_THICKNESS } from '../ice/strength.js'
+import { 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'
@@ -11,6 +12,19 @@ const PER_METRE = PIXELS / TILE
 /** Side of the sheet of ice that is drawn, metres: well past where the fog ends. */
 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`.
+ */
+const MAP_ACROSS = 512
+const MAP_ALONG = 768
+const MAP_BEHIND = 96
+const MAP_STEP = 4
+const MAP_REDRAW = 32
+/** Thick ice is milky with frost and trapped air; this is the colour that adds. */
+const MILK = 0x7d93a6
+
 /** 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]) {
@@ -90,8 +104,12 @@ function paintIce(): HTMLCanvasElement {
 export class Ice {
   readonly mesh: THREE.Mesh
   private readonly texture: THREE.CanvasTexture
+  private readonly map: THREE.DataTexture
+  /** Where the map of thickness was last drawn from: its middle across, its start along. */
+  private mapX = 0
+  private mapS = NaN
 
-  constructor(renderer: THREE.WebGLRenderer) {
+  constructor(renderer: THREE.WebGLRenderer, private readonly lake: Lake) {
     this.texture = new THREE.CanvasTexture(paintIce())
     this.texture.colorSpace = THREE.SRGBColorSpace
     this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping
@@ -99,10 +117,21 @@ export class Ice {
     // 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()
+    // How thick the ice is, is how it looks. Black ice is black because it is thin and
+    // 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,
+      MAP_ALONG / MAP_STEP,
+    )
+    this.map.colorSpace = THREE.SRGBColorSpace
+    this.map.magFilter = THREE.LinearFilter
+    this.map.repeat.set(SIZE / MAP_ACROSS, SIZE / MAP_ALONG)
     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 }),
+      new THREE.MeshLambertMaterial({ map: this.texture, emissive: MILK, emissiveMap: this.map }),
     )
     this.mesh.rotation.x = -Math.PI / 2
   }
@@ -113,5 +142,32 @@ export class Ice {
     // 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)
+    // 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,
+      (s - this.mapS - SIZE / 2) / MAP_ALONG,
+    )
+  }
+
+  private drawMap(s: 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.mapX = Math.round(this.lake.centre(s + MAP_ALONG / 3) / MAP_STEP) * MAP_STEP
+    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++) {
+        const h = this.lake.thickness(
+          this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP,
+          this.mapS + (row + 0.5) * MAP_STEP,
+        )
+        const milk = Math.max(0, (h - BREAKING_THICKNESS) / (MAX_THICKNESS - BREAKING_THICKNESS))
+        data[p++] = data[p++] = data[p++] = Math.round(255 * milk)
+        data[p++] = 255
+      }
+    }
+    this.map.needsUpdate = true
   }
 }
diff --git a/src/style.css b/src/style.css
index 8eab72a..6901933 100644
--- a/src/style.css
+++ b/src/style.css
@@ -34,3 +34,23 @@ body {
 #hint.gone {
   opacity: 0;
 }
+
+/* What the skater sees of the lake from under it. */
+#water {
+  position: fixed;
+  inset: 0;
+  display: grid;
+  place-items: center;
+  background: #06202c;
+  color: #cfe6f2;
+  font: 500 1.4rem/1.4 system-ui, sans-serif;
+  opacity: 0;
+  pointer-events: none;
+  /* Out of the water slowly; into it, below, at once. */
+  transition: opacity 1s;
+}
+
+#water.under {
+  opacity: 1;
+  transition: opacity 0.25s 0.15s;
+}
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index d6b618b..5e15bde 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -22,6 +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 () => {} },
+  {
+    name: 'fell',
+    query: '?s=3055',
+    act: async (page) => {
+      await page.keyboard.down('ArrowRight')
+      await page.waitForFunction(() => document.querySelector('#water').classList.contains('under'), null, { timeout: 15_000 })
+      await page.waitForTimeout(600)
+    },
+  },
   { name: 'landscape', query: '?s=5200', viewport: { width: 844, height: 390 }, act: async () => {} },
   // Ten thousand kilometres down the lake: the picture must be as sharp as at the start.
   { name: 'far', query: '?s=10000000', act: async () => {} },