fuzzyworld

nordic-skating / commit

feat: snow drifts that slow and muffle, and steering by a game controller

8bb08e4acc505b1b121c48a60743d11a209ca23d · Pavel Safronov · 2026-10-10 09:55:26 -0700 · parents c2835cab72e4

Claude Code usage: this commit ≈0 out / ~10.6M cached · cost unknown (no per-model trailer) · session 1 tally 1.2K in / 1.0M out / 180.3M cached · usage by commit and session

feat: snow drifts that slow and muffle, and steering by a game controller

Snow that falls lies: long pale streaks on the ice down the lake's
wind, a few under falling snow and many in a blizzard. In the middle of
a drift a skater is dragged at four times as hard and slows to about
half speed, the ice does not ring or sing, and the blade shushes. Bare
ice between is as fast and as loud as ever. This is the bad ice of the
app's first description.

A game controller's sticks or direction buttons steer, Y or triangle
goes to another lake, and X or square turns the sound on and off.

The two share the main loop and go in together.

Usage: 1.2K in / 1M out / 180.3M cached (session tally)
rtk:   1.7M saved, 25.3% over 2690 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 index.html            |   2 +
 src/audio/iceSound.ts |  19 ++++++--
 src/input.ts          |  45 +++++++++++++++++-
 src/main.ts           |  24 ++++++++--
 src/render/drifts.ts  | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/skater.ts         |   7 ++-
 src/world/lake.ts     |   5 ++
 test/input.test.ts    |  15 +++++-
 test/skater.test.ts   |  11 +++++
 9 files changed, 241 insertions(+), 11 deletions(-)

diff --git a/index.html b/index.html
index 6c77cd4..214c149 100644
--- a/index.html
+++ b/index.html
@@ -40,6 +40,8 @@
     <dd>Tufts of reeds standing in the ice. The skater goes round them unasked; you can weave between them, or circle one.</dd>
     <dt>Sound on or off</dt>
     <dd>The button at the top right, or M.</dd>
+    <dt>With a game controller</dt>
+    <dd>Either stick or the direction buttons steer. Y or triangle goes to another lake; X or square turns the sound on and off.</dd>
     <dt>Another lake</dt>
     <dd>Randomize, at the top left, or R. The browser’s Back button returns to the lake you left.</dd>
     <dt>Time of day, and more</dt>
diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts
index 16d916e..fe80978 100644
--- a/src/audio/iceSound.ts
+++ b/src/audio/iceSound.ts
@@ -44,6 +44,7 @@ export class IceSound {
   private readonly hum: BiquadFilterNode
   private readonly humGain: GainNode
   private readonly scrapeGain: GainNode
+  private readonly snowGain: GainNode
   private readonly tickGain: GainNode
   private readonly splashGain: GainNode
   private readonly gust: BiquadFilterNode
@@ -110,6 +111,14 @@ export class IceSound {
     this.splashGain.gain.value = 0
     this.noise.connect(splash).connect(this.splashGain).connect(this.out)
 
+    // A blade in snow: no ring and no hiss, only a soft, low shush.
+    const shush = ctx.createBiquadFilter()
+    shush.type = 'lowpass'
+    shush.frequency.value = 1100
+    this.snowGain = ctx.createGain()
+    this.snowGain.gain.value = 0
+    this.noise.connect(shush).connect(this.snowGain).connect(this.out)
+
     // The wind: the low end of the same noise, loose and wide, rising in pitch as it blows harder.
     this.gust = ctx.createBiquadFilter()
     this.gust.type = 'bandpass'
@@ -130,10 +139,14 @@ export class IceSound {
    * The blade gliding over ice `h` metres thick. `push` is 1 at the moment of a strike
    * and falls to 0 through the glide that follows it.
    */
-  glide(h: number, push: number, when = this.ctx.currentTime): void {
+  glide(h: number, push: number, when = this.ctx.currentTime, snow = 0): void {
+    // Snow lying on the ice takes the blade off it: `snow` is how much, 0 to 1. The ice
+    // under a drift does not sing, and the blade shushes through it.
+    const bare = 1 - snow
     this.hum.frequency.setTargetAtTime(singingFrequency(h), when, 0.05)
-    this.humGain.gain.setTargetAtTime(0.15 + 0.5 * push, when, 0.03)
-    this.scrapeGain.gain.setTargetAtTime(0.012 + 0.03 * push, when, 0.03)
+    this.humGain.gain.setTargetAtTime((0.15 + 0.5 * push) * bare, when, 0.03)
+    this.scrapeGain.gain.setTargetAtTime((0.012 + 0.03 * push) * bare, when, 0.03)
+    this.snowGain.gain.setTargetAtTime((0.05 + 0.1 * push) * snow, when, 0.05)
   }
 
   /**
diff --git a/src/input.ts b/src/input.ts
index b135b10..285de7f 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -24,18 +24,31 @@ export function tiltSteer(beta: number, gamma: number, turned: number): number {
   return past <= 0 ? 0 : Math.sign(roll) * Math.min(1, past / (TILT_FULL - TILT_REST))
 }
 
+/** How far a controller's stick is from its middle before it counts: a stick at rest is never quite at it. */
+const PAD_REST = 0.15
+
+/** Steering from a controller's stick, pushed `lean` of the way over, -1 to 1: nothing near its middle, and easing in from there. */
+export function padSteer(lean: number): number {
+  const past = Math.abs(lean) - PAD_REST
+  return past <= 0 ? 0 : Math.sign(lean) * Math.min(1, past / (1 - PAD_REST))
+}
+
 /** What iPhones add to the event: steering by tilt has to be asked for, and at a touch. */
 type Asking = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<'granted' | 'denied'> }
 
 /**
  * Steering, from whatever is to hand: a finger or mouse dragged sideways from where it
- * went down, the arrow keys or A and D, or, if it has been turned on, the tilt of a phone.
+ * went down, the arrow keys or A and D, a game controller's stick or direction buttons,
+ * or, if it has been turned on, the tilt of a phone.
  */
 export class Input {
   private pointer: number | null = null
   private startX = 0
   private drag = 0
   private tilt = 0
+  private pad = 0
+  /** The controller buttons that were down the last time they were read. */
+  private padDown = new Set<number>()
   private readonly keys = new Set<string>()
   private readonly tilted = (e: DeviceOrientationEvent): void => {
     if (e.beta === null || e.gamma === null) return
@@ -101,6 +114,34 @@ export class Input {
   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 + this.tilt))
+    return Math.max(-1, Math.min(1, keys + this.drag + this.tilt + this.pad))
+  }
+
+  /**
+   * Read the game controllers, which have to be asked: they send nothing of their own
+   * accord. Called once a frame. Answers which of their buttons have just been pressed,
+   * by the numbers of the standard layout: 2 is X or square, 3 is Y or triangle.
+   */
+  pollPads(): number[] {
+    const pressed: number[] = []
+    const down = new Set<number>()
+    this.pad = 0
+    for (const pad of navigator.getGamepads?.() ?? []) {
+      if (!pad) continue
+      // Either stick, or the direction buttons, whichever is pushed furthest.
+      const left = pad.buttons[14]?.pressed ? -1 : 0
+      const right = pad.buttons[15]?.pressed ? 1 : 0
+      for (const lean of [pad.axes[0] ?? 0, pad.axes[2] ?? 0, left + right]) {
+        const steer = padSteer(lean)
+        if (Math.abs(steer) > Math.abs(this.pad)) this.pad = steer
+      }
+      pad.buttons.forEach((button, n) => {
+        if (!button.pressed) return
+        down.add(n)
+        if (!this.padDown.has(n)) pressed.push(n)
+      })
+    }
+    this.padDown = down
+    return pressed
   }
 }
diff --git a/src/main.ts b/src/main.ts
index 90c62bd..c154452 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,6 +2,7 @@ import './style.css'
 import * as THREE from 'three'
 import { IceSound } from './audio/iceSound.js'
 import { Input } from './input.js'
+import { Drifts } from './render/drifts.js'
 import { Ice } from './render/ice.js'
 import { Marks } from './render/marks.js'
 import { Shores } from './render/shores.js'
@@ -59,7 +60,8 @@ const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500)
 camera.rotation.order = 'YXZ'
 
 // The lake reads this as it goes, so the settings box can change it under a skater.
-const lakeOptions = { impossibleIce: settings.impossibleIce }
+const drifts = new Drifts()
+const lakeOptions = { impossibleIce: settings.impossibleIce, snow: (x: number, s: number) => drifts.cover(x, s) }
 let lake = createLake(seed, lakeOptions)
 const ice = new Ice(renderer, lake)
 const shores = new Shores(lake, seed)
@@ -69,7 +71,7 @@ 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, marks.mesh, shores.group, snow.points, skyLight, sun)
+scene.add(sky.mesh, ice.mesh, drifts.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 {
@@ -82,6 +84,8 @@ function showWorld(): void {
   haze.color.setHSL(horizon.h, horizon.s * (1 - 0.75 * weather.overcast), horizon.l)
   sky.show(look, seed, weather.overcast, haze.color)
   snow.show(weather.snow, look.snow)
+  // Snow that falls lies, in drifts, laid by this lake's wind.
+  drifts.show(seed, weather.snow, windOf(seed))
   // Snow in the air is haze as well: the further shore goes first, and in a blizzard both.
   haze.far = weather.sight
   skyLight.color.set(look.skyLight)
@@ -366,6 +370,14 @@ let untilCrack = 3
 renderer.setAnimationLoop(() => {
   // A tab left in the background comes back with one enormous step; skate it as a short
   // one. And none at all while a box is open over the lake: it waits behind it.
+  // A game controller: its stick steers (input.ts), Y or triangle goes to another lake,
+  // and X or square turns the sound on and off.
+  for (const button of input.pollPads()) {
+    if (button === 3 && !waiting()) randomize()
+    else if (button === 2) setMuted(audio !== null && !muted)
+    // Any button is a touch, as far as a browser's leave to make sound goes.
+    else startSound()
+  }
   const passed = Math.min(clock.getDelta(), 0.1)
   const dt = waiting() ? 0 : passed
   sky.update(clock.elapsedTime)
@@ -405,9 +417,12 @@ renderer.setAnimationLoop(() => {
   if (sound && sounding()) sound.wind(blowing / WEATHER.blizzard.wind[1])
   if (sound && sounding() && 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())
+    // Snow under the blade: the strike is muffled by as much as there is of it, and the
+    // glide shushes instead of singing.
+    const lying = lake.snow(skater.x, skater.s)
+    if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, (0.75 + 0.5 * Math.random()) * (1 - 0.9 * lying))
     // 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))
+    sound.glide(h, Math.exp(-sincePush(skater) / 0.5), undefined, lying)
     untilCrack -= dt * (1 + 14 * strain ** 2)
     if (untilCrack <= 0) {
       // On sound ice: mostly near, now and then from far down the lake.
@@ -445,6 +460,7 @@ renderer.setAnimationLoop(() => {
 
   const facing = Math.cos(look - along)
   ice.update(skater.x, skater.s, facing)
+  drifts.update(skater.x, skater.s)
   shores.update(skater.x, skater.s, skater.channel, facing)
   renderer.render(scene, camera)
 })
diff --git a/src/render/drifts.ts b/src/render/drifts.ts
new file mode 100644
index 0000000..b59a7af
--- /dev/null
+++ b/src/render/drifts.ts
@@ -0,0 +1,124 @@
+import * as THREE from 'three'
+import { mulberry32 } from '../world/lake.js'
+
+/** Side of the square of lake the picture of the drifts covers before it repeats, metres, and its side in pixels. */
+const TILE = 256
+const PIXELS = 512
+/** Side of the sheet they are drawn on, metres: past where the haze ends. */
+const SIZE = 1600
+
+/**
+ * Snow lying on the ice in drifts: long streaks laid down by the wind, all running the
+ * way it blows. Where one lies the ice is covered, and a skater on it is slowed and
+ * hears snow under the blade, not ice (skater.ts, audio/iceSound.ts).
+ *
+ * The drifts are one picture, repeated across the lake and turned to the wind. The same
+ * picture is what is asked where a drift lies, so what is seen and what is felt agree.
+ */
+export class Drifts {
+  readonly mesh: THREE.Mesh
+  private readonly canvas = document.createElement('canvas')
+  private readonly texture: THREE.CanvasTexture
+  private readonly corners: THREE.BufferAttribute
+  /** How much snow lies at each pixel of the picture, 0 to 255; none of it when there are no drifts. */
+  private lying: Uint8ClampedArray | null = null
+  /** The way the wind blows, as how far across the lake and how far along it for each metre. */
+  private windX = 0
+  private windS = 1
+
+  constructor() {
+    this.canvas.width = this.canvas.height = PIXELS
+    this.texture = new THREE.CanvasTexture(this.canvas)
+    this.texture.colorSpace = THREE.SRGBColorSpace
+    this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping
+    // The picture's first row is where its coordinates begin, as it is when it is asked about.
+    this.texture.flipY = false
+    const geometry = new THREE.PlaneGeometry(SIZE, SIZE)
+    this.corners = geometry.getAttribute('uv') as THREE.BufferAttribute
+    this.mesh = new THREE.Mesh(
+      geometry,
+      // Lit like the snow on the banks: white by day, blue by night.
+      new THREE.MeshLambertMaterial({ map: this.texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }),
+    )
+    this.mesh.rotation.x = -Math.PI / 2
+    this.mesh.visible = false
+  }
+
+  /**
+   * Lay the drifts of lake number `seed`: `amount` of them, from 0 for none to 1 for
+   * those of a blizzard, by a wind blowing `windFrom` radians round from down the lake.
+   */
+  show(seed: number, amount: number, windFrom: number): void {
+    this.windX = Math.sin(windFrom)
+    this.windS = Math.cos(windFrom)
+    this.mesh.visible = amount > 0
+    if (amount === 0) {
+      this.lying = null
+      return
+    }
+    const g = this.canvas.getContext('2d', { willReadFrequently: true })!
+    const random = mulberry32(seed ^ 0x9e3779b9)
+    g.clearRect(0, 0, PIXELS, PIXELS)
+    const perMetre = PIXELS / TILE
+    for (let n = 0; n < 20 + 90 * amount; n++) {
+      // A streak: tens of metres long down the wind, a few across, thick in its middle
+      // and thinning to nothing at its edges. The wind is along the picture.
+      const x = random() * PIXELS
+      const y = random() * PIXELS
+      const long = (25 + 80 * random()) * perMetre
+      const wide = (2 + 6 * random() * (0.5 + amount)) * perMetre
+      for (const dx of [-PIXELS, 0, PIXELS]) {
+        for (const dy of [-PIXELS, 0, PIXELS]) {
+          g.save()
+          g.translate(x + dx, y + dy)
+          g.scale(long, wide)
+          const soft = g.createRadialGradient(0, 0, 0, 0, 0, 1)
+          soft.addColorStop(0, 'rgba(236, 241, 246, 0.95)')
+          soft.addColorStop(0.55, 'rgba(236, 241, 246, 0.8)')
+          soft.addColorStop(1, 'rgba(236, 241, 246, 0)')
+          g.fillStyle = soft
+          g.fillRect(-1, -1, 2, 2)
+          g.restore()
+        }
+      }
+    }
+    const picture = g.getImageData(0, 0, PIXELS, PIXELS).data
+    this.lying = new Uint8ClampedArray(PIXELS * PIXELS)
+    for (let i = 0; i < this.lying.length; i++) this.lying[i] = picture[i * 4 + 3]!
+    this.texture.needsUpdate = true
+  }
+
+  /** Where a place on the lake is in the picture, in tiles: down the wind, and across it. */
+  private place(x: number, s: number): [number, number] {
+    return [(x * this.windX + s * this.windS) / TILE, (x * this.windS - s * this.windX) / TILE]
+  }
+
+  /** How much snow lies at `x` across the lake and `s` along it: 0 for bare ice to 1 for a drift's middle. */
+  cover(x: number, s: number): number {
+    if (!this.lying) return 0
+    const [u, v] = this.place(x, s)
+    const col = Math.floor((u - Math.floor(u)) * PIXELS)
+    const row = Math.floor((v - Math.floor(v)) * PIXELS)
+    return this.lying[row * PIXELS + col]! / 255
+  }
+
+  /** Put the right drifts under a skater who is at `x` across and `s` along. */
+  update(x: number, s: number): void {
+    if (!this.mesh.visible) return
+    // The sheet stays under the skater, and each of its corners is told which part of
+    // the picture it is over: worked out here, where the numbers are exact, and with the
+    // whole tiles taken off so that they are small.
+    const [u0, v0] = this.place(x, s)
+    const half = SIZE / 2
+    // The corners as the sheet has them: across, and up the sheet, which is along the lake.
+    const at = [[-half, half], [half, half], [-half, -half], [half, -half]] as const
+    for (const [i, [dx, ds]] of at.entries()) {
+      this.corners.setXY(
+        i,
+        u0 - Math.floor(u0) + (dx * this.windX + ds * this.windS) / TILE,
+        v0 - Math.floor(v0) + (dx * this.windS - ds * this.windX) / TILE,
+      )
+    }
+    this.corners.needsUpdate = true
+  }
+}
diff --git a/src/skater.ts b/src/skater.ts
index 379bc4b..3d6c333 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -23,6 +23,8 @@ export const SPEED = 7
  */
 export const PUSH_SECONDS = 0.5
 const DRAG = 0.42
+/** How much harder than ice a skate is dragged at in the middle of a snow drift, less one. */
+const SNOW_DRAG = 3
 /** The shortest and longest a stride lasts, seconds, and so the most it speeds and slows. */
 export const MIN_STRIDE = 1.4
 export const MAX_STRIDE = 3.2
@@ -356,7 +358,10 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   k.heading += k.turn * dt
 
   const pushing = sincePush(k) < PUSH_SECONDS
-  k.speed += ((pushing ? PUSH : 0) - DRAG * (k.speed / SPEED) ** 2) * dt
+  // Snow on the ice drags at the blade: in the middle of a drift, four times as hard as
+  // ice does, which brings a skater down to about half their speed.
+  const drag = DRAG * (1 + SNOW_DRAG * lake.snow(k.x, k.s))
+  k.speed += ((pushing ? PUSH : 0) - drag * (k.speed / SPEED) ** 2) * dt
 
   // Which channel the skater is in can change; where they are on the ice does not.
   const was = alongside(k)
diff --git a/src/world/lake.ts b/src/world/lake.ts
index 543b807..e501a5d 100644
--- a/src/world/lake.ts
+++ b/src/world/lake.ts
@@ -93,6 +93,8 @@ export interface Lake {
   bankHeight(side: -1 | 1, s: number, d: number): number
   /** Thickness of the ice, metres, at `x` across and `s` along. */
   thickness(x: number, s: number): number
+  /** How much snow lies on the ice at `x` across and `s` along: 0 for bare ice to 1 for the middle of a drift. */
+  snow(x: number, s: number): number
   /**
    * Which way an unhurried skater drifts at `s`, from -1 (as far left as they go) to 1:
    * the long, lazy line the autopilot carves down the lake instead of a straight one.
@@ -189,6 +191,8 @@ export function channelAt(lake: Lake, s: number): Channel {
 export interface LakeOptions {
   /** Whether the ice may be thinner than any ice could be. */
   impossibleIce: boolean
+  /** Where snow lies on the ice, if any does: that is the weather's to say, not the lake's. */
+  snow?: (x: number, s: number) => number
 }
 
 export function createLake(seed: number, options: LakeOptions = { impossibleIce: false }): Lake {
@@ -309,6 +313,7 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce:
       if (!options.impossibleIce) return BREAKING_THICKNESS
       return BREAKING_THICKNESS - (BREAKING_THICKNESS - patch) * IMPOSSIBLE_PLUNGE
     },
+    snow: (x, s) => options.snow?.(x, s) ?? 0,
     carve: (s) => noise(s / 110, 900),
   }
 }
diff --git a/test/input.test.ts b/test/input.test.ts
index 80ddb40..aaa9139 100644
--- a/test/input.test.ts
+++ b/test/input.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { tiltSteer } from '../src/input.js'
+import { padSteer, tiltSteer } from '../src/input.js'
 
 describe('steering by tilt', () => {
   it('is nothing for a phone held level, or nearly', () => {
@@ -29,3 +29,16 @@ describe('steering by tilt', () => {
     expect(tiltSteer(0, 16, 180)).toBeCloseTo(-0.5, 5)
   })
 })
+
+describe('steering by a controller', () => {
+  it('is nothing for a stick at rest, which is never quite at its middle', () => {
+    for (const lean of [0, 0.05, -0.12, 0.15]) expect(padSteer(lean)).toBe(0)
+  })
+
+  it('eases in from there to full steering with the stick right over', () => {
+    expect(padSteer(0.575)).toBeCloseTo(0.5, 5)
+    expect(padSteer(-0.575)).toBeCloseTo(-0.5, 5)
+    expect(padSteer(1)).toBe(1)
+    expect(padSteer(-1)).toBe(-1)
+  })
+})
diff --git a/test/skater.test.ts b/test/skater.test.ts
index 36a1303..b3aa9be 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -299,6 +299,17 @@ describe('skater', () => {
     expect(k.speed).toBeLessThan(pushed - 0.3)
   })
 
+  it('is slowed by snow lying on the ice, and picks up again off it', () => {
+    const snowy: Lake = { ...lake, snow: () => 1 }
+    const k = createSkater(lake, 3300)
+    run(k, 0, 40, STRAIGHT, snowy)
+    // About half speed in the middle of a drift, and still moving.
+    expect(k.speed).toBeLessThan(SPEED * 0.65)
+    expect(k.speed).toBeGreaterThan(SPEED * 0.35)
+    run(k, 0, 40)
+    expect(k.speed).toBeGreaterThan(SPEED * 0.85)
+  })
+
   it('skates the same far down the lake as at its start', () => {
     const k = createSkater(lake, 1e6)
     run(k, 0, 10)