fuzzyworld

nordic-skating / commit

feat: weather: falling snow, blizzards, and a wind that can be heard

236f7d0357de3a450f4b1a46bd3b8f123e7a5698 · Pavel Safronov · 2026-10-10 08:36:57 -0700 · parents ce371025b7ed

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

feat: weather: falling snow, blizzards, and a wind that can be heard

A setting that starts on Surprise me: two lakes in three are clear, a
quarter have snow falling, one in ten a blizzard. Snow is a box of
flakes that stays round the skater; they fall, the wind carries them,
and they stream past as the skater moves. Each lake's wind blows from a
direction of its own, in gusts, and sounds louder and higher in them.
Snow is haze as well: 420 m to see through it, and 200 in a blizzard.
Cloud hides what is in the sky and takes the colour out of a sunset.

Usage: 1K in / 886.4K out / 125.6M cached (session tally)
rtk:   1.7M saved, 25.3% over 2681 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 index.html            | 10 +++++-
 src/audio/iceSound.ts | 18 ++++++++++
 src/main.ts           | 64 ++++++++++++++++++++++++---------
 src/render/snow.ts    | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/settings.ts       | 34 +++++++++++++++++-
 test/settings.test.ts | 30 ++++++++++++++--
 6 files changed, 232 insertions(+), 21 deletions(-)

diff --git a/index.html b/index.html
index 3db032b..ffface0 100644
--- a/index.html
+++ b/index.html
@@ -43,7 +43,7 @@
     <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>
-    <dd>Settings, at the top left: day, dusk, night under the Milky Way, or the northern lights. Left alone, every lake has a time of its own.</dd>
+    <dd>Settings, at the top left: day, dusk, night under the Milky Way, or the northern lights; clear, snowing, or a blizzard. Left alone, every lake has a time and a weather of its own.</dd>
   </dl>
   <p>Every visit is to a different lake, one of a million.</p>
 
@@ -81,6 +81,14 @@
     <label><input type="radio" name="time" value="aurora" /> Aurora <small>the northern lights</small></label>
   </fieldset>
 
+  <fieldset>
+    <legend>Weather</legend>
+    <label><input type="radio" name="weather" value="surprise" /> Surprise me <small>each lake has its own</small></label>
+    <label><input type="radio" name="weather" value="clear" /> Clear</label>
+    <label><input type="radio" name="weather" value="snow" /> Snow <small>falling quietly</small></label>
+    <label><input type="radio" name="weather" value="blizzard" /> Blizzard <small>driven by a high wind</small></label>
+  </fieldset>
+
   <fieldset>
     <legend>Ice</legend>
     <label><input type="checkbox" id="impossible" /> Impossible ice <small>patches thinner than any ice could be: a very high note, a green glow, no cracks</small></label>
diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts
index 2c93280..16d916e 100644
--- a/src/audio/iceSound.ts
+++ b/src/audio/iceSound.ts
@@ -26,6 +26,7 @@ interface Voice {
  *   few hundredths of a second: the chirp of a strike right under you.
  * - **The hum and the scrape.** Between strikes the gliding blade keeps the note faintly
  *   alive, and hisses on the surface.
+ * - **The wind.** When there is one: low, loose noise, louder and higher in its gusts.
  * - **The cracks.** Now and then the ice cracks some way off, and that arrives as the
  *   long falling "pew": high frequencies first, in the order the ice carries them. The
  *   fall is a sine wave, the one pure tone here, and it is gone as soon as it lands on
@@ -45,6 +46,8 @@ export class IceSound {
   private readonly scrapeGain: GainNode
   private readonly tickGain: GainNode
   private readonly splashGain: GainNode
+  private readonly gust: BiquadFilterNode
+  private readonly windGain: GainNode
 
   constructor(private readonly ctx: BaseAudioContext, random: () => number = Math.random) {
     // A limiter, in effect: several rings and a crack can land together.
@@ -106,6 +109,21 @@ export class IceSound {
     this.splashGain = ctx.createGain()
     this.splashGain.gain.value = 0
     this.noise.connect(splash).connect(this.splashGain).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'
+    this.gust.frequency.value = 300
+    this.gust.Q.value = 0.6
+    this.windGain = ctx.createGain()
+    this.windGain.gain.value = 0
+    this.noise.connect(this.gust).connect(this.windGain).connect(this.out)
+  }
+
+  /** The wind over the lake, from 0 for still air to 1 for the worst gust of a blizzard. */
+  wind(strength: number, when = this.ctx.currentTime): void {
+    this.gust.frequency.setTargetAtTime(280 + 520 * strength, when, 0.4)
+    this.windGain.gain.setTargetAtTime(0.9 * strength ** 1.5, when, 0.4)
   }
 
   /**
diff --git a/src/main.ts b/src/main.ts
index b0600f8..3897f4a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,7 +5,8 @@ import { Input } from './input.js'
 import { Ice } from './render/ice.js'
 import { Shores } from './render/shores.js'
 import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js'
-import { anyLake, loadSettings, saveSettings, timeOf } from './settings.js'
+import { Snow } from './render/snow.js'
+import { WEATHER, WEATHERS, type Weather, anyLake, loadSettings, saveSettings, timeOf, weatherOf, windOf } from './settings.js'
 import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js'
 import { PUSH_SECONDS, UNDER_SECONDS, createSkater, sincePush, stepSkater } from './skater.js'
 import { createLake } from './world/lake.js'
@@ -62,16 +63,25 @@ let lake = createLake(seed, lakeOptions)
 const ice = new Ice(renderer, lake)
 const shores = new Shores(lake, seed)
 const sky = new Sky()
+const snow = new Snow()
 // 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, skyLight, sun)
+scene.add(sky.mesh, ice.mesh, shores.group, snow.points, skyLight, sun)
 
-/** Put the lake under the sky and in the light of a time of day. */
-function showTime(time: TimeName): void {
-  const look = LOOKS[time]
-  sky.show(look)
-  haze.color.set(look.horizon)
+/** Put the lake under the sky, in the light and in the weather it is to have. */
+function showWorld(): void {
+  const look = LOOKS[timeNow()]
+  const weather = WEATHER[weatherNow()]
+  // Cloud takes the colour out of a horizon: a sunset seen through snow is grey with a
+  // memory of orange in it.
+  const horizon = { h: 0, s: 0, l: 0 }
+  haze.color.set(look.horizon).getHSL(horizon)
+  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 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)
   skyLight.groundColor.set(look.groundLight)
   skyLight.intensity = look.skyStrength
@@ -81,10 +91,17 @@ function showTime(time: TimeName): void {
   ice.light = look.iceLight
 }
 const asked = params.get('time') as TimeName | null
+const askedWeather = params.get('weather') as Weather | null
 /** The time it is on the lake: what the address asks for, or the settings, or the lake's own. */
-const timeNow = (): TimeName =>
-  asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time
-showTime(timeNow())
+function timeNow(): TimeName {
+  return asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time
+}
+/** And the weather, likewise: `?weather=snow` asks for one whatever the settings say. */
+function weatherNow(): Weather {
+  if (askedWeather && WEATHERS.includes(askedWeather)) return askedWeather
+  return settings.weather === 'surprise' ? weatherOf(seed) : settings.weather
+}
+showWorld()
 
 /** How far below level the skater looks, radians. */
 let pitch = 0
@@ -100,6 +117,7 @@ function resize(): void {
   // the ice coming, rather than fill the top half of the screen with sky.
   pitch = w < h ? 0.3 : 0.12
   camera.updateProjectionMatrix()
+  snow.resize(renderer.domElement.height)
 }
 window.addEventListener('resize', resize)
 resize()
@@ -248,8 +266,8 @@ if (settings.tilt && Input.canTilt) {
 }
 const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only')
 function showSettings(): void {
-  for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[name=time]')) {
-    radio.checked = radio.value === settings.time
+  for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
+    radio.checked = radio.value === settings[radio.name as 'time' | 'weather']
   }
   impossible.checked = settings.impossibleIce
   tiltButton.textContent = settings.tilt ? '📱 Tilt on' : '📱 Tilt off'
@@ -262,9 +280,9 @@ function showSettings(): void {
 showSettings()
 settingsBox.addEventListener('change', (e) => {
   const changed = e.target as HTMLInputElement
-  if (changed.name === 'time') {
-    settings.time = changed.value as typeof settings.time
-    showTime(timeNow())
+  if (changed.name === 'time' || changed.name === 'weather') {
+    ;(settings[changed.name] as string) = changed.value
+    showWorld()
   } else if (changed === impossible) {
     settings.impossibleIce = lakeOptions.impossibleIce = impossible.checked
     ice.redraw()
@@ -285,7 +303,7 @@ function enterLake(to: number): void {
   ice.show(lake)
   shores.show(lake, seed)
   skater = createSkater(lake)
-  showTime(timeNow())
+  showWorld()
   showLake()
   // One lake does not turn into another: the haze closes in and clears on the new one.
   veil.style.background = haze.color.getStyle()
@@ -341,8 +359,15 @@ 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.
-  const dt = waiting() ? clock.getDelta() * 0 : Math.min(clock.getDelta(), 0.1)
+  const passed = Math.min(clock.getDelta(), 0.1)
+  const dt = waiting() ? 0 : passed
   sky.update(clock.elapsedTime)
+  // The wind: between its lull and its gust, never the same for long, from where the
+  // lake has it coming from.
+  const { wind: [lull, gusting] } = WEATHER[weatherNow()]
+  const t = clock.elapsedTime
+  const blowing = lull + (gusting - lull) * (0.5 + 0.5 * Math.sin(t * 0.23) * Math.sin(t * 0.071 + 1))
+  const from = windOf(seed)
   const steer = input.steer()
   if (steer !== 0) hint.classList.add('gone')
   const strides = Math.floor(skater.stride)
@@ -355,6 +380,7 @@ renderer.setAnimationLoop(() => {
   // In the water for all but the last second, which is the climb back out.
   water.classList.toggle('under', skater.under > 1)
 
+  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())
@@ -388,6 +414,10 @@ renderer.setAnimationLoop(() => {
   const look = skater.heading - 0.25 * Math.sin(skater.heading - along)
   camera.rotation.set(-pitch, -look, -0.02 * skater.turn * skater.speed)
 
+  // The snow falls on while the lake waits behind a box; the skater does not move through it.
+  const moving = dt > 0 ? skater.speed : 0
+  snow.update(passed, blowing * Math.sin(from), blowing * Math.cos(from), moving * Math.sin(skater.heading), moving * Math.cos(skater.heading))
+
   const facing = Math.cos(look - along)
   ice.update(skater.x, skater.s, facing)
   shores.update(skater.x, skater.s, skater.channel, facing)
diff --git a/src/render/snow.ts b/src/render/snow.ts
new file mode 100644
index 0000000..54d0bb7
--- /dev/null
+++ b/src/render/snow.ts
@@ -0,0 +1,97 @@
+import * as THREE from 'three'
+import { mulberry32 } from '../world/lake.js'
+
+/** The box of air round the skater that has snow in it: metres across, up, and along. */
+const BOX = [44, 22, 44] as const
+/** Snowflakes in a blizzard; lighter snow shows a share of them. */
+const FLAKES = 6000
+/** How fast a snowflake falls, m/s. */
+const FALL = 1.5
+
+const vertex = /* glsl */ `
+  attribute float aOrder;
+  uniform vec3 uBox;
+  uniform vec3 uDrift;
+  uniform float uAmount;
+  uniform float uPixels;
+  varying float vShow;
+  void main() {
+    // Every flake is somewhere in the box, and the box goes round: what leaves one side
+    // of it comes in at the other, so the air is always full without a flake being made.
+    vec3 at = fract(position + uDrift) * uBox - vec3(uBox.x * 0.5, 3.0, uBox.z * 0.5);
+    vec4 seen = modelViewMatrix * vec4(at, 1.0);
+    gl_Position = projectionMatrix * seen;
+    float far = length(seen.xyz);
+    gl_PointSize = uPixels * (0.05 + 0.06 * fract(aOrder * 7.3)) / far;
+    // Lighter snow is fewer flakes; and they fade out towards the edge of the box.
+    vShow = step(aOrder, uAmount) * smoothstep(21.0, 14.0, far) * smoothstep(0.3, 1.5, far);
+  }
+`
+
+const fragment = /* glsl */ `
+  uniform vec3 uColour;
+  varying float vShow;
+  void main() {
+    float soft = smoothstep(0.5, 0.15, length(gl_PointCoord - 0.5));
+    if (vShow * soft < 0.01) discard;
+    gl_FragColor = vec4(uColour, vShow * soft * 0.9);
+    #include <colorspace_fragment>
+  }
+`
+
+/**
+ * Falling snow: a box of flakes that stays round the skater, through which the flakes
+ * fall, are blown by the wind, and stream past as the skater moves.
+ */
+export class Snow {
+  readonly points: THREE.Points
+  private readonly uniforms = {
+    uBox: { value: new THREE.Vector3(...BOX) },
+    uDrift: { value: new THREE.Vector3() },
+    uAmount: { value: 0 },
+    uPixels: { value: 1 },
+    uColour: { value: new THREE.Color() },
+  }
+
+  constructor() {
+    const random = mulberry32(1947)
+    const places = new Float32Array(FLAKES * 3)
+    const order = new Float32Array(FLAKES)
+    for (let i = 0; i < FLAKES; i++) {
+      for (let c = 0; c < 3; c++) places[i * 3 + c] = random()
+      order[i] = random()
+    }
+    const geometry = new THREE.BufferGeometry()
+    geometry.setAttribute('position', new THREE.BufferAttribute(places, 3))
+    geometry.setAttribute('aOrder', new THREE.BufferAttribute(order, 1))
+    this.points = new THREE.Points(geometry, new THREE.ShaderMaterial({
+      uniforms: this.uniforms, vertexShader: vertex, fragmentShader: fragment,
+      transparent: true, depthWrite: false,
+    }))
+    this.points.frustumCulled = false
+  }
+
+  /** How much snow: 0 for none, 1 for a blizzard; and its colour in this light. */
+  show(amount: number, colour: number): void {
+    this.uniforms.uAmount.value = amount
+    this.uniforms.uColour.value.set(colour)
+    this.points.visible = amount > 0
+  }
+
+  /** `height` is the height of the picture in pixels, which sets how big a flake is drawn. */
+  resize(height: number): void {
+    this.uniforms.uPixels.value = height
+  }
+
+  /**
+   * Move the snow on by `dt` seconds: falling, blown by a wind of `windX` m/s across the
+   * lake and `windS` along it, and left behind by a skater moving at `moveX` and `moveS`.
+   */
+  update(dt: number, windX: number, windS: number, moveX: number, moveS: number): void {
+    const drift = this.uniforms.uDrift.value
+    // What is drawn looks down -z, so along the lake is the other way round.
+    drift.x = (drift.x + ((windX - moveX) * dt) / BOX[0]) % 1
+    drift.y = (drift.y - (FALL * dt) / BOX[1]) % 1
+    drift.z = (drift.z - ((windS - moveS) * dt) / BOX[2]) % 1
+  }
+}
diff --git a/src/settings.ts b/src/settings.ts
index 71bc76f..696d371 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,17 +1,34 @@
 import { TIME_NAMES, type TimeName } from './render/sky.js'
 import { mulberry32 } from './world/lake.js'
 
+/** The weathers a lake can have. */
+export const WEATHERS = ['clear', 'snow', 'blizzard'] as const
+export type Weather = (typeof WEATHERS)[number]
+
+/**
+ * What a weather is: how much snow is falling, from 0 to 1; how far the eye can see
+ * through it, metres; how much of the sky is cloud; and how hard the wind blows, m/s,
+ * at its lulls and in its gusts.
+ */
+export const WEATHER: Record<Weather, { snow: number; sight: number; overcast: number; wind: readonly [number, number] }> = {
+  clear: { snow: 0, sight: 600, overcast: 0, wind: [0, 0] },
+  snow: { snow: 0.35, sight: 420, overcast: 0.7, wind: [0.5, 3] },
+  blizzard: { snow: 1, sight: 200, overcast: 0.95, wind: [7, 16] },
+}
+
 /** What the skater has chosen in the settings box, kept from one visit to the next. */
 export interface Settings {
   /** A time of day, or `surprise` for whichever the lake comes with. */
   time: TimeName | 'surprise'
+  /** The weather, or `surprise` for whichever the lake comes with. */
+  weather: Weather | 'surprise'
   /** Whether the lake has ice thinner than any ice could be (world/lake.ts). */
   impossibleIce: boolean
   /** Whether tilting the phone steers (input.ts). */
   tilt: boolean
 }
 
-const DEFAULTS: Settings = { time: 'surprise', impossibleIce: false, tilt: false }
+const DEFAULTS: Settings = { time: 'surprise', weather: 'surprise', impossibleIce: false, tilt: false }
 const KEY = 'settings'
 
 export function loadSettings(): Settings {
@@ -19,6 +36,7 @@ export function loadSettings(): Settings {
     const kept = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Partial<Settings>
     return {
       time: kept.time === 'surprise' || TIME_NAMES.includes(kept.time as TimeName) ? kept.time! : DEFAULTS.time,
+      weather: kept.weather === 'surprise' || WEATHERS.includes(kept.weather as Weather) ? kept.weather! : DEFAULTS.weather,
       impossibleIce: kept.impossibleIce === true,
       tilt: kept.tilt === true,
     }
@@ -50,3 +68,17 @@ export function timeOf(seed: number): TimeName {
   const r = mulberry32(seed ^ 0x5bd1e995)()
   return r < 0.35 ? 'day' : r < 0.55 ? 'dusk' : r < 0.82 ? 'night' : 'aurora'
 }
+
+/**
+ * The weather lake number `seed` comes with. Most days are clear; snow falls on a
+ * quarter of them, and a blizzard blows on one in ten.
+ */
+export function weatherOf(seed: number): Weather {
+  const r = mulberry32(seed ^ 0x3c6ef372)()
+  return r < 0.64 ? 'clear' : r < 0.9 ? 'snow' : 'blizzard'
+}
+
+/** Which way the wind blows over lake number `seed`, radians: 0 is down the lake, a quarter turn is across it to the right. */
+export function windOf(seed: number): number {
+  return mulberry32(seed ^ 0x1b873593)() * Math.PI * 2
+}
diff --git a/test/settings.test.ts b/test/settings.test.ts
index 9c3bf16..55bbeca 100644
--- a/test/settings.test.ts
+++ b/test/settings.test.ts
@@ -1,11 +1,11 @@
 import { describe, expect, it } from 'vitest'
 import { TIME_NAMES } from '../src/render/sky.js'
-import { anyLake, loadSettings, timeOf } from '../src/settings.js'
+import { WEATHER, WEATHERS, anyLake, loadSettings, timeOf, weatherOf, windOf } from '../src/settings.js'
 
 describe('settings', () => {
   it('starts with a surprise for the time of day, no impossible ice, and steering by touch', () => {
     // There is no storage here, which is also what a private window is like.
-    expect(loadSettings()).toEqual({ time: 'surprise', impossibleIce: false, tilt: false })
+    expect(loadSettings()).toEqual({ time: 'surprise', weather: 'surprise', impossibleIce: false, tilt: false })
   })
 
   it('picks a lake from a million', () => {
@@ -27,4 +27,30 @@ describe('settings', () => {
     expect(count.day).toBe(Math.max(...Object.values(count)))
     expect(count.aurora).toBe(Math.min(...Object.values(count)))
   })
+
+  it('gives each lake a weather of its own: mostly clear, sometimes snow, now and then a blizzard', () => {
+    const count = Object.fromEntries(WEATHERS.map((name) => [name, 0]))
+    for (let lake = 1; lake <= 20_000; lake++) {
+      expect(weatherOf(lake)).toBe(weatherOf(lake))
+      count[weatherOf(lake)]!++
+      expect(windOf(lake)).toBeGreaterThanOrEqual(0)
+      expect(windOf(lake)).toBeLessThan(2 * Math.PI)
+    }
+    expect(count.clear! / 20_000).toBeGreaterThan(0.55)
+    expect(count.snow! / 20_000).toBeGreaterThan(0.2)
+    expect(count.blizzard! / 20_000).toBeGreaterThan(0.06)
+    expect(count.blizzard).toBeLessThan(count.snow!)
+  })
+
+  it('has weather that gets worse in every way together', () => {
+    const [clear, falling, blizzard] = WEATHERS.map((name) => WEATHER[name])
+    expect(clear!.snow).toBe(0)
+    expect(clear!.wind[1]).toBe(0)
+    for (const [milder, worse] of [[clear!, falling!], [falling!, blizzard!]] as const) {
+      expect(worse.snow).toBeGreaterThan(milder.snow)
+      expect(worse.sight).toBeLessThan(milder.sight)
+      expect(worse.overcast).toBeGreaterThan(milder.overcast)
+      expect(worse.wind[1]).toBeGreaterThan(milder.wind[1])
+    }
+  })
 })