fuzzyworld

nordic-skating / commit

feat(ice): zones of ice from what it will carry, and going through the thinnest

6216d5cb34802338b2f139dc388e5fb98b667683 · Pavel Safronov · 2026-10-09 07:27:45 -0700 · parents 9ff4c3fcd873

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

feat(ice): zones of ice from what it will carry, and going through the thinnest

The zones are Gold's three thresholds for the load a sheet of ice
carries (A·h², Gold 1971), worked out for an 80 kg skater: under 2.2 cm
it gives way, to 3.4 cm it holds and cracks, to 4.8 cm it is sound ice
near its limit, and beyond that it is safe. Nothing is chosen.

The lake's ice now runs from 1.4 to 7 cm so that every zone is on it:
of what 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 the
film's 4.1 cm, bridged where a thin patch crosses it, so the autopilot
skates for ever.

A skater on ice that gives way is in the water for four seconds and
skates on from the middle.

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>
 src/ice/strength.ts   | 37 +++++++++++++++++++++++++++++++++++++
 src/skater.ts         | 23 ++++++++++++++++++++++-
 src/world/lake.ts     | 30 +++++++++++++++++++++++-------
 test/lake.test.ts     | 36 +++++++++++++++++++++++++++++++++++-
 test/skater.test.ts   | 29 +++++++++++++++++++++++++++--
 test/strength.test.ts | 29 +++++++++++++++++++++++++++++
 6 files changed, 173 insertions(+), 11 deletions(-)

diff --git a/src/ice/strength.ts b/src/ice/strength.ts
new file mode 100644
index 0000000..da800a3
--- /dev/null
+++ b/src/ice/strength.ts
@@ -0,0 +1,37 @@
+/**
+ * How much ice it takes to hold a skater up.
+ *
+ * Gold's formula, from the record of what did and did not go through the ice roads of
+ * Canada: a sheet `h` cm thick carries a load of `A · h²` kg, where `A` says how much
+ * risk is being run. L. W. Gold, "Use of ice covers for transportation", Canadian
+ * Geotechnical Journal 8, 170–181 (1971).
+ */
+
+/** Gold's `A`, kg/cm²: safe, failure unlikely. */
+export const A_SAFE = 3.5
+/** The upper limit for ordinary use: beyond it the ice is being asked a lot. */
+export const A_LIMIT = 7
+/** Beyond this, failure should be expected. */
+export const A_FAILURE = 17
+
+/** A skater with clothes, skates and a pack, kg. */
+export const SKATER_MASS = 80
+
+/** Thickness of ice, metres, that carries `mass` kg at risk `a`. */
+export function bearingThickness(mass: number, a: number): number {
+  return Math.sqrt(mass / a) / 100
+}
+
+/**
+ * The three thicknesses that matter to one skater, metres. They are the zones of the
+ * lake, and nothing about them is chosen: they are Gold's three values of `A`.
+ *
+ * - Thinner than `BREAKING` (2.2 cm) the ice gives way.
+ * - Up to `CRACKING` (3.4 cm) it holds, and cracks under the skater as it does.
+ * - Up to `SAFE` (4.8 cm) it is sound ice run close to its limit: the ice of the film,
+ *   at 4.1 cm, and the ice that sings best.
+ * - Beyond that it is safe, and sings lower the thicker it gets.
+ */
+export const BREAKING_THICKNESS = bearingThickness(SKATER_MASS, A_FAILURE)
+export const CRACKING_THICKNESS = bearingThickness(SKATER_MASS, A_LIMIT)
+export const SAFE_THICKNESS = bearingThickness(SKATER_MASS, A_SAFE)
diff --git a/src/skater.ts b/src/skater.ts
index 7acd544..b0bd933 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -1,3 +1,4 @@
+import { BREAKING_THICKNESS } from './ice/strength.js'
 import { type Lake, mulberry32 } from './world/lake.js'
 
 /** Cruising speed, m/s: about 25 km/h, an easy pace on good ice. */
@@ -26,6 +27,9 @@ export function strideSeconds(index: number): number {
   return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()
 }
 
+/** Seconds between going through the ice and skating on from the middle of the lake. */
+export const UNDER_SECONDS = 4
+
 export interface Skater {
   /** Distance skated along the lake, metres. */
   s: number
@@ -39,10 +43,12 @@ export interface Skater {
   idle: 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. */
+  under: number
 }
 
 export function createSkater(s = 0): Skater {
-  return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0 }
+  return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0, under: 0 }
 }
 
 /** Where the skater is across the lake, in the same metres as `Lake.centre`. */
@@ -56,8 +62,22 @@ export function skaterX(k: Skater, lake: Lake): number {
  * 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.
+ *
+ * Ice too thin to hold a skater (ice/strength.ts) gives way. The skater is then in the
+ * water where they fell for `UNDER_SECONDS`, and skates on from the middle of the lake,
+ * where the ice is always sound (world/lake.ts).
  */
 export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): void {
+  if (k.under > 0) {
+    k.under = Math.max(0, k.under - dt)
+    k.lateral = 0
+    if (k.under === 0) {
+      k.offset = 0
+      k.heading = Math.atan(lake.slope(k.s))
+      k.idle = Infinity
+    }
+    return
+  }
   const before = k.offset
   if (Math.abs(steer) > 0.05) {
     k.idle = 0
@@ -77,4 +97,5 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo
   // The view turns into a change of direction over a moment rather than snapping to it.
   k.heading += (Math.atan(slope) - k.heading) * (1 - Math.exp(-dt / 0.25))
   k.stride += dt / strideSeconds(Math.floor(k.stride))
+  if (lake.thickness(skaterX(k, lake), k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS
 }
diff --git a/src/world/lake.ts b/src/world/lake.ts
index 18e0058..3bdf7b9 100644
--- a/src/world/lake.ts
+++ b/src/world/lake.ts
@@ -35,11 +35,20 @@ export interface Lake {
 export const MIN_HALF_WIDTH = 12
 export const MAX_HALF_WIDTH = 44
 /**
- * The thinnest and thickest the ice gets, metres. The ice in the film this is modelled on
- * measures 4.1 cm (tools/analyse-video.mjs), and that is the middle of the range.
+ * The thinnest and thickest the ice gets, metres: from ice that will not hold a skater
+ * (ice/strength.ts) to ice half as thick again as it needs to be.
  */
-export const MIN_THICKNESS = 0.025
-export const MAX_THICKNESS = 0.057
+export const MIN_THICKNESS = 0.014
+export const MAX_THICKNESS = 0.07
+/**
+ * The ice down the middle of the lake is never thinner than this, metres, which is what
+ * lets the autopilot skate for ever: it is the 4.1 cm of the film this is modelled on
+ * (tools/analyse-video.mjs), sound ice that sings.
+ */
+export const MIDDLE_THICKNESS = 0.041
+/** How far either side of the middle that holds, metres, and where it has faded out. */
+const MIDDLE_HOLDS = 3
+const MIDDLE_FADES = 8
 
 export function createLake(seed: number): Lake {
   const noise = createNoise2D(mulberry32(seed))
@@ -58,8 +67,15 @@ export function createLake(seed: number): Lake {
       const rise = 8 * (1 - Math.exp(-d / 25)) + 0.06 * d
       return rise * (0.55 + 0.45 * noise(s / 140, side * 37 + d / 90))
     },
-    // Ice thickens and thins in broad patches, longer along the lake than across it.
-    thickness: (x, s) =>
-      (MIN_THICKNESS + MAX_THICKNESS) / 2 + ((MAX_THICKNESS - MIN_THICKNESS) / 2) * noise(x / 60 + 500, s / 180),
+    thickness: (x, s) => {
+      // Ice thickens and thins in broad patches, longer along the lake than across it.
+      const patch = (MIN_THICKNESS + MAX_THICKNESS) / 2 + ((MAX_THICKNESS - MIN_THICKNESS) / 2) * noise(x / 60 + 500, s / 180)
+      if (patch >= MIDDLE_THICKNESS) return patch
+      // Where a thin patch crosses the middle of the lake, a strip of sound ice bridges it.
+      const off = Math.abs(x - centre(s))
+      const bridge = 1 - Math.min(1, Math.max(0, (off - MIDDLE_HOLDS) / (MIDDLE_FADES - MIDDLE_HOLDS)))
+      if (bridge === 1) return MIDDLE_THICKNESS
+      return patch + (MIDDLE_THICKNESS - patch) * bridge * bridge * (3 - 2 * bridge)
+    },
   }
 }
diff --git a/test/lake.test.ts b/test/lake.test.ts
index dadd621..b76dc44 100644
--- a/test/lake.test.ts
+++ b/test/lake.test.ts
@@ -1,5 +1,8 @@
 import { describe, expect, it } from 'vitest'
-import { MAX_HALF_WIDTH, MAX_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, createLake } from '../src/world/lake.js'
+import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js'
+import {
+  MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, createLake,
+} from '../src/world/lake.js'
 
 describe('lake', () => {
   it('is the same lake for the same seed, and another for another', () => {
@@ -50,4 +53,35 @@ describe('lake', () => {
       }
     }
   })
+
+  it('always has sound ice down its middle', () => {
+    const lake = createLake(1)
+    for (let s = 0; s < 200_000; s += 5) {
+      for (const off of [-2.9, 0, 2.9]) expect(lake.thickness(lake.centre(s) + off, s)).toBeGreaterThanOrEqual(MIDDLE_THICKNESS)
+    }
+  })
+
+  it('has every kind of ice within reach of a skater, most of it sound', () => {
+    const lake = createLake(1)
+    const zones = { breaking: 0, cracking: 0, singing: 0, safe: 0 }
+    let all = 0
+    for (let s = 0; s < 100_000; s += 10) {
+      const reach = lake.halfWidth(s) - 2
+      for (let off = -reach; off <= reach; off += 2) {
+        const h = lake.thickness(lake.centre(s) + off, s)
+        if (h < BREAKING_THICKNESS) zones.breaking++
+        else if (h < CRACKING_THICKNESS) zones.cracking++
+        else if (h < SAFE_THICKNESS) zones.singing++
+        else zones.safe++
+        all++
+      }
+    }
+    // Ice that gives way is there to be found, and rare enough to be avoided.
+    expect(zones.breaking / all).toBeGreaterThan(0.005)
+    expect(zones.breaking / all).toBeLessThan(0.08)
+    expect(zones.cracking / all).toBeGreaterThan(0.1)
+    expect(zones.singing / all).toBeGreaterThan(0.2)
+    expect(zones.safe / all).toBeGreaterThan(0.2)
+    console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', '))
+  })
 })
diff --git a/test/skater.test.ts b/test/skater.test.ts
index 566bb06..1d76e7d 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -1,9 +1,10 @@
 import { describe, expect, it } from 'vitest'
 import {
-  IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED,
+  IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS,
   createSkater, skaterX, stepSkater, strideSeconds,
 } from '../src/skater.js'
-import { createLake } from '../src/world/lake.js'
+import { BREAKING_THICKNESS } from '../src/ice/strength.js'
+import { type Lake, createLake } from '../src/world/lake.js'
 
 const DT = 1 / 60
 const lake = createLake(1)
@@ -78,4 +79,28 @@ describe('skater', () => {
     run(k, 0, 10)
     expect(k.s - 1e7).toBeGreaterThan(SPEED * 10 * 0.9)
   })
+
+  it('left alone, never goes through the ice', () => {
+    const k = createSkater()
+    for (let t = 0; t < 3600; t += DT) {
+      stepSkater(k, lake, 0, DT)
+      expect(k.under).toBe(0)
+    }
+  })
+
+  it('goes through ice too thin to hold, waits in the water, and skates on from the middle', () => {
+    // A lake that is sound down the middle and gives way anywhere 5 m to the right of it.
+    const thin: Lake = { ...lake, thickness: (x, s) => (x - lake.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) }
+    const k = createSkater()
+    while (k.under === 0) stepSkater(k, thin, 1, DT)
+    expect(k.offset).toBeGreaterThan(5)
+    const where = k.s
+    for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT)
+    expect(k.under).toBeGreaterThan(0)
+    expect(k.s).toBe(where)
+    for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT)
+    expect(k.under).toBe(0)
+    expect(k.offset).toBe(0)
+    expect(k.s).toBeGreaterThan(where)
+  })
 })
diff --git a/test/strength.test.ts b/test/strength.test.ts
new file mode 100644
index 0000000..d6b740f
--- /dev/null
+++ b/test/strength.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { singingFrequency } from '../src/ice/acoustics.js'
+import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS, bearingThickness } from '../src/ice/strength.js'
+import { MAX_THICKNESS, MIDDLE_THICKNESS, MIN_THICKNESS } from '../src/world/lake.js'
+
+describe('ice strength', () => {
+  it('needs twice the ice for four times the load', () => {
+    expect(bearingThickness(320, 7) / bearingThickness(80, 7)).toBeCloseTo(2, 10)
+  })
+
+  it('gives a skater zones of 2.2, 3.4 and 4.8 cm', () => {
+    expect(BREAKING_THICKNESS * 100).toBeCloseTo(2.2, 1)
+    expect(CRACKING_THICKNESS * 100).toBeCloseTo(3.4, 1)
+    expect(SAFE_THICKNESS * 100).toBeCloseTo(4.8, 1)
+  })
+
+  it('puts the film\'s ice, and the middle of the lake, in the zone that sings', () => {
+    expect(MIDDLE_THICKNESS).toBeGreaterThan(CRACKING_THICKNESS)
+    expect(MIDDLE_THICKNESS).toBeLessThan(SAFE_THICKNESS)
+  })
+
+  it('has every zone somewhere on the lake, and a pitch that tells them apart', () => {
+    expect(MIN_THICKNESS).toBeLessThan(BREAKING_THICKNESS)
+    expect(MAX_THICKNESS).toBeGreaterThan(SAFE_THICKNESS)
+    // Breaking ice sings above 1.4 kHz, safe ice below 640 Hz: more than an octave apart.
+    expect(singingFrequency(BREAKING_THICKNESS)).toBeGreaterThan(1390)
+    expect(singingFrequency(SAFE_THICKNESS)).toBeLessThan(640)
+  })
+})