fuzzyworld

nordic-skating / commit

feat(ice): cracks that look like cracks, in the ice and from the skater

c123191e1e30e38c496884c270bf27183099f1c9 · Pavel Safronov · 2026-10-10 15:22:21 -0700 · parents 893cda77555a

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

feat(ice): cracks that look like cracks, in the ice and from the skater

The ice's own cracks were thirty jagged lines at random. They are now a
network made the way ice cracks: a few long runs, nearly straight, and
later cracks that leave an earlier one at a wide angle and stop where
they meet the next, since a crack cannot cross a crack. Each is painted
with a bright edge and its face going down into the ice beside it.

The cracks the skater makes were drawn into a picture with ten pixels
to the metre, and looked thick and pixelated. They, the skate tracks and
the push scratches are now lines of their own, a pixel wide at any
distance. A crack is a star of a few forking arms, and runs out from
its middle over a moment instead of appearing whole.

Usage: 1.3K in / 1.1M out / 189.3M cached (session tally)
rtk:   1.7M saved, 25.3% over 2693 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 src/ice/cracks.ts   | 154 ++++++++++++++++++++++++++++++++++++++++++
 src/main.ts         |   4 +-
 src/render/ice.ts   |  49 +++++++++-----
 src/render/marks.ts | 189 ++++++++++++++++++++++++----------------------------
 test/cracks.test.ts |  78 ++++++++++++++++++++++
 5 files changed, 355 insertions(+), 119 deletions(-)

diff --git a/src/ice/cracks.ts b/src/ice/cracks.ts
new file mode 100644
index 0000000..2f469cb
--- /dev/null
+++ b/src/ice/cracks.ts
@@ -0,0 +1,154 @@
+/**
+ * How ice cracks, as far as how it looks goes.
+ *
+ * A sheet of black ice is crossed by a few long cracks that run nearly straight for tens
+ * of metres. Later cracks start from the side of an earlier one, run off at a wide angle
+ * to it, and stop when they meet another: a crack cannot cross a crack, since the ice is
+ * already parted there. So the cracks make a network of long runs joined at T's, with
+ * shorter and finer ones filling in between, and never a tangle of lines crossing at
+ * random.
+ */
+
+export type Point = [number, number]
+
+export interface Crack {
+  /** Where it runs, in metres. On a sheet that repeats, it may run off one edge: it comes in at the other. */
+  points: Point[]
+  /** 0 for one of the long first cracks, 1 for one that runs between them, 2 for a hairline. */
+  rank: 0 | 1 | 2
+}
+
+/** Metres between the points of a crack. */
+const STEP = 0.25
+/** How much a crack of each rank wanders from straight at each step, radians. */
+const WANDER = [0.035, 0.07, 0.12] as const
+
+/** Where along the step from `a` to `b` it meets the stretch from `c` to `d`, as a fraction of the step; nowhere if it does not. */
+function meets(a: Point, b: Point, c: Point, d: Point): number | undefined {
+  const rx = b[0] - a[0]
+  const ry = b[1] - a[1]
+  const sx = d[0] - c[0]
+  const sy = d[1] - c[1]
+  const cross = rx * sy - ry * sx
+  if (Math.abs(cross) < 1e-12) return undefined
+  const t = ((c[0] - a[0]) * sy - (c[1] - a[1]) * sx) / cross
+  const u = ((c[0] - a[0]) * ry - (c[1] - a[1]) * rx) / cross
+  return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : undefined
+}
+
+/**
+ * The cracks of a square sheet of ice `size` metres on a side that repeats in both
+ * directions: `first` long ones, then `between` that run from one crack to another,
+ * then `fine` hairlines. `random` gives numbers from 0 to 1, and the same numbers give
+ * the same cracks.
+ */
+export function crackNetwork(random: () => number, size: number, first = 5, between = 26, fine = 44): Crack[] {
+  const cracks: Crack[] = []
+  // Every step of every crack so far, kept by the metre square its middle is in, so that
+  // a new step is tried only against the steps near it.
+  const cells = Math.ceil(size)
+  const near: [Point, Point][][] = Array.from({ length: cells * cells }, () => [])
+  const cell = (x: number, y: number): number =>
+    (((Math.floor(x) % cells) + cells) % cells) + ((((Math.floor(y) % cells) + cells) % cells) * cells)
+  const keep = (a: Point, b: Point): void => void near[cell((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)]!.push([a, b])
+
+  /** Where the step from `a` to `b` first meets a crack already there, as a fraction of it. */
+  const stopped = (a: Point, b: Point): number | undefined => {
+    let first: number | undefined
+    const mx = (a[0] + b[0]) / 2
+    const my = (a[1] + b[1]) / 2
+    for (let dx = -1; dx <= 1; dx++) {
+      for (let dy = -1; dy <= 1; dy++) {
+        for (const [c, d] of near[cell(mx + dx, my + dy)]!) {
+          // The sheet repeats: the stretch is tried where its nearest copy to this step lies.
+          const ox = Math.round((mx - (c[0] + d[0]) / 2) / size) * size
+          const oy = Math.round((my - (c[1] + d[1]) / 2) / size) * size
+          const t = meets(a, b, [c[0] + ox, c[1] + oy], [d[0] + ox, d[1] + oy])
+          // Not at the step's very start, which is where a crack leaves the one it began on.
+          if (t !== undefined && t > 1e-9 && (first === undefined || t < first)) first = t
+        }
+      }
+    }
+    return first
+  }
+
+  /** Run a crack from `from` in direction `way` for up to `far` metres, or until it meets another. */
+  const run = (from: Point, way: number, far: number, rank: 0 | 1 | 2): Point[] => {
+    const points: Point[] = [from]
+    for (let gone = 0; gone < far; gone += STEP) {
+      way += (random() - 0.5) * 2 * WANDER[rank]
+      // Now and then a sharper turn, where the ice gives along a different grain.
+      if (random() < 0.03) way += (random() - 0.5) * 0.5
+      const a = points[points.length - 1]!
+      const b: Point = [a[0] + Math.cos(way) * STEP, a[1] + Math.sin(way) * STEP]
+      const t = stopped(a, b)
+      if (t !== undefined) {
+        points.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
+        break
+      }
+      points.push(b)
+    }
+    return points
+  }
+
+  const add = (points: Point[], rank: 0 | 1 | 2): void => {
+    if (points.length < 3) return
+    for (let i = 1; i < points.length; i++) keep(points[i - 1]!, points[i]!)
+    cracks.push({ points, rank })
+  }
+
+  // The long first cracks: from somewhere, both ways, most of the way across the sheet.
+  for (let n = 0; n < first; n++) {
+    const from: Point = [random() * size, random() * size]
+    const way = random() * Math.PI * 2
+    const one = run(from, way, size * (0.5 + 0.4 * random()), 0)
+    const other = run(from, way + Math.PI, size * (0.5 + 0.4 * random()), 0)
+    add([...other.reverse(), ...one.slice(1)], 0)
+  }
+  // The rest start from a point on a crack already there and leave it at a wide angle.
+  const branch = (far: () => number, rank: 1 | 2): void => {
+    const parent = cracks[Math.floor(random() * cracks.length)]!
+    const at = 1 + Math.floor(random() * (parent.points.length - 2))
+    const [px, py] = parent.points[at]!
+    const [qx, qy] = parent.points[at - 1]!
+    const along = Math.atan2(py - qy, px - qx)
+    const off = (random() < 0.5 ? 1 : -1) * (Math.PI / 2 + (random() - 0.5) * 1.1)
+    add(run([px, py], along + off, far(), rank), rank)
+  }
+  for (let n = 0; n < between; n++) branch(() => 3 + 11 * random(), 1)
+  for (let n = 0; n < fine; n++) branch(() => 0.8 + 3.5 * random(), 2)
+  return cracks
+}
+
+/**
+ * A crack opening now, under a skater or near one: a star of a few cracks running out
+ * from one point, about `far` metres each, each forking once or twice. Every stretch of
+ * it comes with how far along the crack from that point it is, in metres, for whoever
+ * wants to show it running outwards.
+ */
+export function crackStar(random: () => number, far: number): { from: Point; to: Point; along: number }[] {
+  const stretches: { from: Point; to: Point; along: number }[] = []
+  const arm = (start: Point, way: number, length: number, gone: number, forks: number): void => {
+    let at = start
+    for (let run = 0; run < length; run += STEP) {
+      way += (random() - 0.5) * 0.16
+      if (random() < 0.05) way += (random() - 0.5) * 0.6
+      const to: Point = [at[0] + Math.cos(way) * STEP, at[1] + Math.sin(way) * STEP]
+      stretches.push({ from: at, to, along: gone + run })
+      at = to
+      // A fork: a shorter crack off to one side, from here.
+      if (forks > 0 && random() < 0.5 * STEP) {
+        forks--
+        arm(at, way + (random() < 0.5 ? 1 : -1) * (0.5 + 0.6 * random()), (length - run) * (0.3 + 0.4 * random()), gone + run, 0)
+      }
+    }
+  }
+  // Two arms nearly opposite, which is the crack itself, and often a third or fourth.
+  const way = random() * Math.PI * 2
+  const arms = 2 + Math.floor(random() * 2.6)
+  for (let n = 0; n < arms; n++) {
+    const turn = n < 2 ? n * Math.PI + (random() - 0.5) * 0.4 : random() * Math.PI * 2
+    arm([0, 0], way + turn, far * (n < 2 ? 0.6 + 0.4 * random() : 0.25 + 0.4 * random()), 0, 2)
+  }
+  return stretches
+}
diff --git a/src/main.ts b/src/main.ts
index c154452..ce11826 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -95,6 +95,8 @@ function showWorld(): void {
   sun.intensity = look.sunStrength
   sun.position.set(...look.sunFrom)
   ice.light = look.iceLight
+  // The marks on the ice are seen by the same light, though never quite lost in the dark.
+  marks.light = 0.25 + 0.75 * look.iceLight
 }
 const asked = params.get('time') as TimeName | null
 const askedWeather = params.get('weather') as Weather | null
@@ -395,7 +397,7 @@ renderer.setAnimationLoop(() => {
   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)
+  marks.follow(skater.x, skater.s, dt)
   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)
diff --git a/src/render/ice.ts b/src/render/ice.ts
index ca69085..b10974d 100644
--- a/src/render/ice.ts
+++ b/src/render/ice.ts
@@ -1,4 +1,5 @@
 import * as THREE from 'three'
+import { crackNetwork } from '../ice/cracks.js'
 import { BREAKING_THICKNESS } from '../ice/strength.js'
 import { type Lake, MAX_THICKNESS, MIN_THICKNESS, mulberry32 } from '../world/lake.js'
 
@@ -48,8 +49,8 @@ function wrapped(g: CanvasRenderingContext2D, paint: () => void): void {
 
 /**
  * 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.
+ * one flat colour and the skater appears to stand still. Frost in soft patches, the
+ * network of white cracks that black ice is laced with, and bubbles frozen into it.
  */
 function paintIce(): HTMLCanvasElement {
   const canvas = document.createElement('canvas')
@@ -75,23 +76,37 @@ function paintIce(): HTMLCanvasElement {
 
   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, () => {
+  // The cracks (ice/cracks.ts). A crack in clear ice is not a line on it but a thin
+  // white sheet standing in it, seen from above and a little to one side: a bright edge
+  // where it meets the surface, and its face going down into the ice beside that, fainter
+  // and wider. So each is drawn three times, widest and faintest first; and the long ones
+  // once more to one side, for the face.
+  const LOOK = [
+    { core: [1.4, 0.9], body: [3.6, 0.17], haze: [10, 0.05] },
+    { core: [1.0, 0.72], body: [2.2, 0.13], haze: [5.5, 0.04] },
+    { core: [0.8, 0.42], body: [1.6, 0.07], haze: [0, 0] },
+  ] as const
+  for (const { points, rank } of crackNetwork(random, TILE)) {
+    const trace = (dx: number, dy: number): void => {
       g.beginPath()
-      points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x, y) : g.lineTo(x, y)))
+      points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x * PER_METRE + dx, y * PER_METRE + dy) : g.lineTo(x * PER_METRE + dx, y * PER_METRE + dy)))
       g.stroke()
+    }
+    // Some are older than others, and have healed: milkier and less sharp.
+    const fresh = 0.7 + 0.3 * random()
+    const look = LOOK[rank]
+    wrapped(g, () => {
+      for (const [width, alpha] of [look.haze, look.body, look.core]) {
+        if (width === 0) continue
+        g.strokeStyle = `rgba(214, 234, 245, ${alpha * fresh})`
+        g.lineWidth = width
+        trace(0, 0)
+      }
+      if (rank === 0) {
+        g.strokeStyle = `rgba(214, 234, 245, ${0.16 * fresh})`
+        g.lineWidth = 1.2
+        trace(3.2, 2.1)
+      }
     })
   }
 
diff --git a/src/render/marks.ts b/src/render/marks.ts
index 9b1cc60..294229e 100644
--- a/src/render/marks.ts
+++ b/src/render/marks.ts
@@ -1,98 +1,115 @@
 import * as THREE from 'three'
+import { crackStar } from '../ice/cracks.js'
 
-/** 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
+/** How many stretches of line the lake remembers; when there are more, the oldest are forgotten. */
+const STRETCHES = 9000
 /** The line of the skates is drawn on in stretches this long, metres. */
-const TRACK_STEP = 0.7
+const TRACK_STEP = 0.5
+/** How fast a crack runs out from where it starts, m/s: quick, and slow enough to see go. */
+const CRACK_SPEED = 28
+/** When the skater is this far from where the marks are measured from, metres, they are measured from nearer. */
+const RECENTRE = 400
+
+type Colour = readonly [number, number, number, number]
+/** What each kind of mark looks like: its colour, and how solid it is. */
+const TRACK: Colour = [0.82, 0.9, 0.95, 0.5]
+const PUSH: Colour = [0.9, 0.95, 0.98, 0.75]
+const CRACK: Colour = [0.93, 0.97, 1, 0.95]
 
 /**
  * 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.
+ * Every mark is a hairline: a line with no width of its own, drawn a pixel wide however
+ * near it is, which is what a scratch or a crack in ice is. A crack runs out from where
+ * it starts over a moment, not all at once.
+ *
+ * The marks stay where they were made, so a skater who turns back, or circles an islet,
+ * comes on their own tracks, as in the film. The lake remembers the last few thousand
+ * stretches of line and forgets the ones before.
  */
 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
+  readonly mesh: THREE.LineSegments
+  private readonly places = new Float32Array(STRETCHES * 6)
+  private readonly colours = new Float32Array(STRETCHES * 8)
+  private readonly geometry = new THREE.BufferGeometry()
+  /** How many stretches have ever been drawn; the next goes in the place this leaves over. */
+  private drawn = 0
+  /** Where on the lake the marks are measured from: across, and along. */
+  private fromX = 0
+  private fromS = 0
   /** Where the line of the skates was last drawn to; nowhere when the pen is up. */
   private last: [number, number] | null = null
+  /** The stretches of cracks still to appear, soonest first, and the time by this clock. */
+  private coming: { at: number; ax: number; as: number; bx: number; bs: number }[] = []
+  private clock = 0
 
   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.geometry.setAttribute('position', new THREE.BufferAttribute(this.places, 3))
+    this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colours, 4))
+    this.geometry.setDrawRange(0, 0)
+    this.mesh = new THREE.LineSegments(
+      this.geometry,
+      new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, depthWrite: false }),
     )
-    this.mesh.rotation.x = -Math.PI / 2
+    // They are never far from the skater, and are moved with them; nothing is gained by asking.
+    this.mesh.frustumCulled = false
     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]
+  /** How brightly the marks show: 1 by day, less by the light of a night. */
+  set light(strength: number) {
+    ;(this.mesh.material as THREE.LineBasicMaterial).color.setScalar(strength)
   }
 
   /** 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.drawn = 0
+    this.fromX = x
+    this.fromS = s
     this.last = null
-    this.texture.needsUpdate = true
+    this.coming = []
+    this.geometry.setDrawRange(0, 0)
   }
 
-  /** 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
+  /** One stretch of line, from one place on the lake to another. */
+  private stretch(ax: number, as: number, bx: number, bs: number, colour: Colour): void {
+    const n = this.drawn++ % STRETCHES
+    // A hair above the ice, so that it is the line that shows and not the ice through it.
+    this.places.set([ax - this.fromX, 0.02, -(as - this.fromS), bx - this.fromX, 0.02, -(bs - this.fromS)], n * 6)
+    this.colours.set([...colour, ...colour], n * 8)
+    this.geometry.getAttribute('position').needsUpdate = true
+    this.geometry.getAttribute('color').needsUpdate = true
+    this.geometry.setDrawRange(0, Math.min(this.drawn, STRETCHES) * 2)
+  }
+
+  /**
+   * Keep the marks where they lie on the ice, for a skater at `x` across and `s` along,
+   * and let the cracks that are opening run on by `dt` seconds.
+   */
+  follow(x: number, s: number, dt: number): void {
+    if (Math.abs(x - this.fromX) > RECENTRE || Math.abs(s - this.fromS) > RECENTRE) {
+      // Measured from where the skater now is, so that the numbers stay small.
+      for (let i = 0; i < this.places.length; i += 3) {
+        this.places[i] = this.places[i]! + this.fromX - x
+        this.places[i + 2] = this.places[i + 2]! - (this.fromS - s)
+      }
+      this.fromX = x
+      this.fromS = s
+      this.geometry.getAttribute('position').needsUpdate = true
+    }
+    this.mesh.position.set(this.fromX - x, 0, -(this.fromS - s))
+    this.clock += dt
+    while (this.coming.length > 0 && this.coming[0]!.at <= this.clock) {
+      const { ax, as, bx, bs } = this.coming.shift()!
+      this.stretch(ax, as, bx, bs, CRACK)
     }
-    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
-    }
+    if (this.last) this.stretch(this.last[0], this.last[1], x, s, TRACK)
     this.last = [x, s]
   }
 
@@ -106,48 +123,18 @@ export class Marks {
     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
+    this.stretch(footX, footS, footX + 1.1 * Math.sin(out), footS + 1.1 * Math.cos(out), PUSH)
   }
 
   /**
-   * 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.
+   * A crack opening in the ice at `x`, `s`: a few arms running out from there, about
+   * `length` metres each, forking as they go (ice/cracks.ts), over the moment it takes
+   * a crack to run that far.
    */
   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()
+    for (const { from, to, along } of crackStar(Math.random, length)) {
+      this.coming.push({ at: this.clock + along / CRACK_SPEED, ax: x + from[0], as: s + from[1], bx: x + to[0], bs: s + to[1] })
     }
-    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
+    this.coming.sort((a, b) => a.at - b.at)
   }
 }
diff --git a/test/cracks.test.ts b/test/cracks.test.ts
new file mode 100644
index 0000000..6b9af19
--- /dev/null
+++ b/test/cracks.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest'
+import { type Point, crackNetwork, crackStar } from '../src/ice/cracks.js'
+import { mulberry32 } from '../src/world/lake.js'
+
+const SIZE = 32
+const network = crackNetwork(mulberry32(2018), SIZE)
+
+/** Whether the stretch a-b crosses the stretch c-d somewhere inside both, not at an end of either. */
+function crosses(a: Point, b: Point, c: Point, d: Point): boolean {
+  const side = (p: Point, q: Point, r: Point): number => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
+  const eps = 1e-6
+  return side(a, b, c) * side(a, b, d) < -eps && side(c, d, a) * side(c, d, b) < -eps
+}
+
+describe('cracks', () => {
+  it('are the same cracks for the same numbers', () => {
+    expect(crackNetwork(mulberry32(2018), SIZE)).toEqual(network)
+    expect(crackNetwork(mulberry32(2019), SIZE)).not.toEqual(network)
+  })
+
+  it('are a few long ones, more between them, and many hairlines', () => {
+    const length = (points: Point[]): number =>
+      points.slice(1).reduce((sum, p, i) => sum + Math.hypot(p[0] - points[i]![0], p[1] - points[i]![1]), 0)
+    const of = (rank: number): number[] => network.filter((c) => c.rank === rank).map((c) => length(c.points))
+    expect(of(0).length).toBe(5)
+    expect(of(1).length).toBeGreaterThan(15)
+    expect(of(2).length).toBeGreaterThan(25)
+    const mean = (lengths: number[]): number => lengths.reduce((a, b) => a + b) / lengths.length
+    expect(mean(of(0))).toBeGreaterThan(15)
+    expect(mean(of(1))).toBeLessThan(mean(of(0)))
+    expect(mean(of(2))).toBeLessThan(mean(of(1)))
+  })
+
+  it('run nearly straight', () => {
+    for (const { points, rank } of network.filter((c) => c.rank < 2 && c.points.length > 20)) {
+      const end = points[points.length - 1]!
+      const straight = Math.hypot(end[0] - points[0]![0], end[1] - points[0]![1])
+      const along = (points.length - 1) * 0.25
+      // As the crow flies, most of the way it actually runs.
+      expect(straight / along).toBeGreaterThan(rank === 0 ? 0.8 : 0.6)
+    }
+  })
+
+  it('stop at one another and never cross', () => {
+    // Every stretch of every crack, with which crack it is of; and the sheet repeats, so
+    // each is tried against the others' copies in the sheets round it as well.
+    const stretches = network.flatMap((crack, which) =>
+      crack.points.slice(1).map((p, i) => ({ a: crack.points[i]!, b: p, which })))
+    let crossings = 0
+    for (const one of stretches) {
+      for (const other of stretches) {
+        if (other.which <= one.which) continue
+        for (const ox of [-SIZE, 0, SIZE]) {
+          for (const oy of [-SIZE, 0, SIZE]) {
+            if (crosses(one.a, one.b, [other.a[0] + ox, other.a[1] + oy], [other.b[0] + ox, other.b[1] + oy])) crossings++
+          }
+        }
+      }
+    }
+    // A later crack may be run through by nothing; an earlier one may, at the very start
+    // of a crack leaving it, be touched. Crossings proper there are none of, or next to.
+    expect(crossings).toBeLessThanOrEqual(3)
+  })
+
+  it('open as a star of arms from one point, in order outwards', () => {
+    const star = crackStar(mulberry32(7), 6)
+    expect(star.length).toBeGreaterThan(20)
+    expect(star.filter((s) => s.along === 0 && s.from[0] === 0 && s.from[1] === 0).length).toBeGreaterThanOrEqual(2)
+    let furthest = 0
+    for (const { from, to, along } of star) {
+      expect(Math.hypot(to[0] - from[0], to[1] - from[1])).toBeCloseTo(0.25, 5)
+      expect(along).toBeGreaterThanOrEqual(0)
+      furthest = Math.max(furthest, Math.hypot(to[0], to[1]))
+    }
+    expect(furthest).toBeGreaterThan(2)
+    expect(furthest).toBeLessThan(7)
+  })
+})