fuzzyworld

nordic-skating / commit

feat: the skater leaves tracks on the ice, and cracks where it is thin

b8af453df11df293727de07bac5a1eac1fafd7b7 · Pavel Safronov · 2026-10-10 09:23:10 -0700 · parents 26ace8f53dd3

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

feat: the skater leaves tracks on the ice, and cracks where it is thin

The skates leave a pale line, and each push a scratch to one side and
then the other. On ice thin enough to crack, a crack is drawn where each
near one is heard from, and a push on such ice starts more from under
the skate.

The marks lie on the hundred metres square of ice round the skater,
which stays put on the lake as they move on it: turn back, or circle an
islet, and there are your own tracks. Further off, the lake forgets.

Usage: 1.1K in / 978.6K out / 157.4M cached (session tally)
rtk:   1.7M saved, 25.3% over 2685 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 src/main.ts         |  34 +++++++++---
 src/render/marks.ts | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 181 insertions(+), 6 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index adfda40..90c62bd 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,6 +3,7 @@ import * as THREE from 'three'
 import { IceSound } from './audio/iceSound.js'
 import { Input } from './input.js'
 import { Ice } from './render/ice.js'
+import { Marks } from './render/marks.js'
 import { Shores } from './render/shores.js'
 import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js'
 import { Snow } from './render/snow.js'
@@ -64,10 +65,11 @@ const ice = new Ice(renderer, lake)
 const shores = new Shores(lake, seed)
 const sky = new Sky()
 const snow = new Snow()
+const marks = new Marks()
 // The light of the whole sky, and the sun or moon, which gives the banks and trees a lit side.
 const skyLight = new THREE.HemisphereLight()
 const sun = new THREE.DirectionalLight()
-scene.add(sky.mesh, ice.mesh, shores.group, snow.points, skyLight, sun)
+scene.add(sky.mesh, ice.mesh, marks.mesh, shores.group, snow.points, skyLight, sun)
 
 /** Put the lake under the sky, in the light and in the weather it is to have. */
 function showWorld(): void {
@@ -306,6 +308,7 @@ function enterLake(to: number): void {
   ice.show(lake)
   shores.show(lake, seed)
   skater = createSkater(lake)
+  marks.clear(skater.x, skater.s)
   showWorld()
   showLake()
   // One lake does not turn into another: the haze closes in and clears on the new one.
@@ -356,6 +359,7 @@ function keepAwake(): void {
 keepAwake()
 document.addEventListener('visibilitychange', keepAwake)
 window.addEventListener('pointerdown', keepAwake, { once: true })
+marks.clear(skater.x, skater.s)
 /** Seconds until the ice next cracks somewhere off across the lake. */
 let untilCrack = 3
 
@@ -377,6 +381,21 @@ renderer.setAnimationLoop(() => {
   const wasUnder = skater.under > 0
   stepSkater(skater, lake, steer, dt)
   const h = lake.thickness(skater.x, skater.s)
+  // What the skates leave on the ice: their line, and a scratch at each push, off the
+  // left skate and the right in turn.
+  marks.follow(skater.x, skater.s)
+  if (dt > 0) marks.track(skater.x, skater.s)
+  const pushed = Math.floor(skater.stride) !== strides
+  if (pushed) marks.push(skater.x, skater.s, skater.heading, Math.floor(skater.stride) % 2 ? 1 : -1)
+  // Ice near its limit cracks under the skater: the thinner, the more often and the
+  // nearer, until at the breaking thickness it is cracking every second or so. Thinner
+  // than that it is not real ice, and does not crack at all (world/lake.ts).
+  const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS))
+  // A push on such ice starts cracks from under the skate, which is seen whether or not
+  // there is sound to hear it by.
+  if (pushed && strain > 0.2) {
+    for (let n = 0; n < 1 + 3 * strain; n++) marks.crack(skater.x, skater.s, 1 + 5 * strain * Math.random())
+  }
   const fell = skater.under > 0 && !wasUnder
   // Nothing is asked of the sound while it is off: it would all play at once on its return.
   if (fell && sounding()) sound?.fall(h)
@@ -389,15 +408,18 @@ renderer.setAnimationLoop(() => {
     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.
     sound.glide(h, Math.exp(-sincePush(skater) / 0.5))
-    // Ice near its limit cracks under the skater: the thinner, the more often and the
-    // nearer, until at the breaking thickness it is cracking every second or so.
-    // Thinner than that it is not real ice, and does not crack at all (world/lake.ts).
-    const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS))
     untilCrack -= dt * (1 + 14 * strain ** 2)
     if (untilCrack <= 0) {
       // 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)
+      const pan = Math.random() * 2 - 1
+      sound.crack(h, distance, undefined, pan)
+      // A crack near enough to see is drawn where it was heard from: that far off, and
+      // to the side of the skater it sounded on.
+      if (distance < 40) {
+        const towards = skater.heading + pan * 1.4
+        marks.crack(skater.x + distance * Math.sin(towards), skater.s + distance * Math.cos(towards), 3 + 9 * Math.random())
+      }
       untilCrack = 3 + 12 * Math.random()
     }
   }
diff --git a/src/render/marks.ts b/src/render/marks.ts
new file mode 100644
index 0000000..9b1cc60
--- /dev/null
+++ b/src/render/marks.ts
@@ -0,0 +1,153 @@
+import * as THREE from 'three'
+
+/** Side of the square of ice round the skater that keeps its marks, metres, and its picture's side in pixels. */
+const SIZE = 100
+const PIXELS = 1024
+const PER_METRE = PIXELS / SIZE
+/** When the skater is this far from the middle of the square, metres, it is moved to be round them again. */
+const RECENTRE = 22
+/** The line of the skates is drawn on in stretches this long, metres. */
+const TRACK_STEP = 0.7
+
+/**
+ * What the skater leaves on the ice: the lines of their skates, the scratch of each push,
+ * and, on ice thin enough to crack, the cracks.
+ *
+ * They are drawn on a picture laid over the ice round the skater, a hundred metres square,
+ * which stays where it is on the ice while the skater moves about on it. So a skater who
+ * turns back, or circles an islet, comes on their own tracks, as in the film. When the
+ * skater nears its edge the picture is moved along, and what was at its far side is lost:
+ * the lake does not remember further than that.
+ */
+export class Marks {
+  readonly mesh: THREE.Mesh
+  private readonly canvas = document.createElement('canvas')
+  private readonly pen: CanvasRenderingContext2D
+  private readonly texture: THREE.CanvasTexture
+  /** Where the middle of the square is on the lake: across, and along. */
+  private midX = 0
+  private midS = 0
+  /** Where the line of the skates was last drawn to; nowhere when the pen is up. */
+  private last: [number, number] | null = null
+
+  constructor() {
+    this.canvas.width = this.canvas.height = PIXELS
+    this.pen = this.canvas.getContext('2d')!
+    this.pen.lineCap = 'round'
+    this.pen.lineJoin = 'round'
+    this.texture = new THREE.CanvasTexture(this.canvas)
+    this.texture.colorSpace = THREE.SRGBColorSpace
+    this.mesh = new THREE.Mesh(
+      new THREE.PlaneGeometry(SIZE, SIZE),
+      // Lit like the ice it lies on, so that the marks are pale by day and dim by night.
+      new THREE.MeshLambertMaterial({ map: this.texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }),
+    )
+    this.mesh.rotation.x = -Math.PI / 2
+    this.mesh.renderOrder = 1
+  }
+
+  /** Where a place on the lake is in the picture, in pixels: its top is the far end. */
+  private at(x: number, s: number): [number, number] {
+    return [(x - this.midX + SIZE / 2) * PER_METRE, (SIZE / 2 - (s - this.midS)) * PER_METRE]
+  }
+
+  /** Another lake, or a skater set down somewhere else: nothing has been marked here. */
+  clear(x: number, s: number): void {
+    this.pen.clearRect(0, 0, PIXELS, PIXELS)
+    this.midX = x
+    this.midS = s
+    this.last = null
+    this.texture.needsUpdate = true
+  }
+
+  /** Keep the picture where it lies on the ice, for a skater at `x` across and `s` along. */
+  follow(x: number, s: number): void {
+    if (Math.abs(x - this.midX) > RECENTRE || Math.abs(s - this.midS) > RECENTRE) {
+      // Moved by whole pixels, so that what is kept is not blurred by the move.
+      const dx = Math.round((x - this.midX) * PER_METRE)
+      const ds = Math.round((s - this.midS) * PER_METRE)
+      this.pen.save()
+      this.pen.globalCompositeOperation = 'copy'
+      this.pen.drawImage(this.canvas, -dx, ds)
+      this.pen.restore()
+      this.midX += dx / PER_METRE
+      this.midS += ds / PER_METRE
+      this.texture.needsUpdate = true
+    }
+    this.mesh.position.set(this.midX - x, 0, -(this.midS - s))
+  }
+
+  /** The line of the skates, on to where the skater now is. */
+  track(x: number, s: number): void {
+    // A stretch at a time, not a frame at a time: every stretch drawn is the whole
+    // picture sent to the graphics card again.
+    if (this.last && Math.hypot(x - this.last[0], s - this.last[1]) < TRACK_STEP) return
+    const to = this.at(x, s)
+    if (this.last) {
+      const from = this.at(...this.last)
+      this.pen.strokeStyle = 'rgba(214, 232, 244, 0.55)'
+      this.pen.lineWidth = 1.1
+      this.pen.beginPath()
+      this.pen.moveTo(...from)
+      this.pen.lineTo(...to)
+      this.pen.stroke()
+      this.texture.needsUpdate = true
+    }
+    this.last = [x, s]
+  }
+
+  /**
+   * The scratch of a push: a short line off to one side of the way the skater is going,
+   * `foot` being -1 for a push off the left skate and 1 for the right.
+   */
+  push(x: number, s: number, heading: number, foot: number): void {
+    // The skate is a little to that side of the skater, and is pushed out and back: a
+    // hundred degrees round from the way they are going.
+    const footX = x + foot * 0.25 * Math.cos(heading)
+    const footS = s - foot * 0.25 * Math.sin(heading)
+    const out = heading + foot * 1.75
+    const [fromX, fromY] = this.at(footX, footS)
+    const [toX, toY] = this.at(footX + 1.3 * Math.sin(out), footS + 1.3 * Math.cos(out))
+    this.pen.strokeStyle = 'rgba(232, 242, 250, 0.7)'
+    this.pen.lineWidth = 1.6
+    this.pen.beginPath()
+    this.pen.moveTo(fromX, fromY)
+    this.pen.lineTo(toX, toY)
+    this.pen.stroke()
+    this.texture.needsUpdate = true
+  }
+
+  /**
+   * A crack opening in the ice at `x`, `s`: a jagged line about `length` metres long that
+   * forks once or twice, running whichever way it runs.
+   */
+  crack(x: number, s: number, length: number): void {
+    const run = (fromX: number, fromS: number, way: number, far: number, wide: number): void => {
+      let px = fromX
+      let ps = fromS
+      this.pen.strokeStyle = 'rgba(236, 246, 252, 0.8)'
+      this.pen.lineWidth = wide
+      this.pen.beginPath()
+      this.pen.moveTo(...this.at(px, ps))
+      for (let gone = 0; gone < far; ) {
+        const step = 0.3 + Math.random() * 0.7
+        // Kinked at every step, but about the one way it runs: a crack does not curl.
+        const kinked = way + (Math.random() - 0.5) * 1.2
+        px += Math.sin(kinked) * step
+        ps += Math.cos(kinked) * step
+        gone += step
+        this.pen.lineTo(...this.at(px, ps))
+        // A fork: a shorter, finer crack off to one side, drawn when this one is done.
+        if (wide > 1 && Math.random() < 0.12) forks.push([px, ps, way + (Math.random() < 0.5 ? 0.9 : -0.9), (far - gone) * 0.6])
+      }
+      this.pen.stroke()
+    }
+    const forks: [number, number, number, number][] = []
+    const way = Math.random() * Math.PI * 2
+    // Both ways from where it starts.
+    run(x, s, way, length / 2, 1.5)
+    run(x, s, way + Math.PI, length / 2, 1.5)
+    for (const [fx, fs, fway, far] of forks) run(fx, fs, fway, far, 0.9)
+    this.texture.needsUpdate = true
+  }
+}