fuzzyworld

nordic-skating / commit

feat: five paces, from a meander to an Olympic sprint; and pictures on the buttons

1ee103b4b9af2d731969a95087a653d6d70291cf · Pavel Safronov · 2026-10-10 17:35:06 -0700 · parents da5f527bfc88

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

feat: five paces, from a meander to an Olympic sprint; and pictures on the buttons

Pace. Meander (13 km/h), Cruise (25, as it was), Brisk (36), Racing (45)
and Olympic (54, a long-track speed skater's pace). A button at the
bottom right steps through them, as do the up and down keys and a
controller, and they are in Settings. A change of pace is worked up to
over a few strides. Strides shorten and quicken with pace, the skater
crouches, the view widens and the air rushes past. A faster skater is
held to half a g sideways, so turns in a wider circle, looks and steers
further ahead, and is kept further from the shores.

Pictures. The pace button has the app's skate. The sound button has a
speaker and a speaker crossed out, and the tilt button a phone in a
hand and a phone by itself, in place of emoji. Each is line art whose
shape is filled with the colour of the button's words. They are kept on
the phone with the rest of the app, for use with no network.

Fixed on the way: a tap on a button's picture, not its edge, was taken
as a tap on the lake, which for the sound button turned the sound on
and straight off again.

The two share the page and the main loop and go in together.

Usage: 1.4K in / 1.2M out / 220.6M cached (session tally)
rtk:   1.7M saved, 25.3% over 2701 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 assets/sound-off.png  | Bin 0 -> 10565 bytes
 assets/sound-on.png   | Bin 0 -> 11144 bytes
 assets/tilt-off.png   | Bin 0 -> 5198 bytes
 assets/tilt-on.png    | Bin 0 -> 17118 bytes
 index.html            |  16 ++++++-
 src/main.ts           |  99 ++++++++++++++++++++++++++++++++++++++-----
 src/settings.ts       |   6 ++-
 src/skater.ts         | 115 +++++++++++++++++++++++++++++++++++---------------
 src/style.css         |  37 ++++++++++++++++
 test/settings.test.ts |   2 +-
 test/skater.test.ts   |  72 ++++++++++++++++++++++++++++++-
 tools/icons.mjs       |   3 +-
 vite.config.ts        |   4 +-
 13 files changed, 301 insertions(+), 53 deletions(-)

diff --git a/assets/sound-off.png b/assets/sound-off.png
new file mode 100644
index 0000000..4853d36
Binary files /dev/null and b/assets/sound-off.png differ
diff --git a/assets/sound-on.png b/assets/sound-on.png
new file mode 100644
index 0000000..7d887b3
Binary files /dev/null and b/assets/sound-on.png differ
diff --git a/assets/tilt-off.png b/assets/tilt-off.png
new file mode 100644
index 0000000..9e3057f
Binary files /dev/null and b/assets/tilt-off.png differ
diff --git a/assets/tilt-on.png b/assets/tilt-on.png
new file mode 100644
index 0000000..51b95f2
Binary files /dev/null and b/assets/tilt-on.png differ
diff --git a/index.html b/index.html
index 214c149..9fe94ef 100644
--- a/index.html
+++ b/index.html
@@ -24,6 +24,7 @@
   <button id="randomize" type="button">Randomize</button>
 </nav>
 <button id="sound" type="button"></button>
+<button id="pace-button" type="button"></button>
 <!-- Shown only on what can be tilted (main.ts). -->
 <button id="tilt-button" type="button" hidden></button>
 <dialog id="about-dialog" aria-labelledby="about-title">
@@ -38,10 +39,12 @@
     <dd>A touch towards it on the way up to the fork is enough. Do nothing and the skater picks one.</dd>
     <dt>Islets</dt>
     <dd>Tufts of reeds standing in the ice. The skater goes round them unasked; you can weave between them, or circle one.</dd>
+    <dt>Go faster or slower</dt>
+    <dd>The button at the bottom right steps through five paces, from a meander to an Olympic sprint; or use ↑ ↓ or W S.</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>
+    <dd>Either stick or the direction buttons steer; up and down on the direction buttons, or the shoulder buttons, change pace. 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>
@@ -67,7 +70,7 @@
   <p>Inspired by National Geographic’s film <a href="https://www.youtube.com/watch?v=v3O9vNi-dkA" target="_blank" rel="noopener">Hear the Otherworldly Sounds of Skating on Thin Ice</a>.</p>
   <p><a href="https://gitlab.com/PavelSafronov/nordic-skating/-/blob/main/README.md" target="_blank" rel="noopener">How it works, and credits</a> · <a href="https://gitlab.com/PavelSafronov/nordic-skating" target="_blank" rel="noopener">Source</a></p>
   <!-- The Flaticon free licence requires the credit wherever the artwork is used. -->
-  <p><small>App icon by <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Icongeek26</a>, from Flaticon.</small></p>
+  <p><small>Icons by <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Icongeek26</a>, from Flaticon.</small></p>
 
   <form method="dialog"><button id="about-close" class="done">Skate on</button></form>
 </dialog>
@@ -83,6 +86,15 @@
     <label><input type="radio" name="time" value="aurora" /> Aurora <small>the northern lights</small></label>
   </fieldset>
 
+  <fieldset>
+    <legend>Pace</legend>
+    <label><input type="radio" name="pace" value="meander" /> Meander <small>13 km/h, in long lazy strides</small></label>
+    <label><input type="radio" name="pace" value="cruise" /> Cruise <small>25 km/h</small></label>
+    <label><input type="radio" name="pace" value="brisk" /> Brisk <small>36 km/h</small></label>
+    <label><input type="radio" name="pace" value="racing" /> Racing <small>45 km/h</small></label>
+    <label><input type="radio" name="pace" value="olympic" /> Olympic <small>54 km/h, as if training for the Games</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>
diff --git a/src/main.ts b/src/main.ts
index ce11826..67387f5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,9 @@
 import './style.css'
+import skateIcon from '../assets/skate-shoes.png'
+import soundOffIcon from '../assets/sound-off.png'
+import soundOnIcon from '../assets/sound-on.png'
+import tiltOffIcon from '../assets/tilt-off.png'
+import tiltOnIcon from '../assets/tilt-on.png'
 import * as THREE from 'three'
 import { IceSound } from './audio/iceSound.js'
 import { Input } from './input.js'
@@ -10,7 +15,7 @@ import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.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 { PACES, PACE_NAMES, type Pace, UNDER_SECONDS, createSkater, pushSeconds, sincePush, stepSkater } from './skater.js'
 import { createLake } from './world/lake.js'
 
 // Eye height of a skater leaning into a stride, in metres.
@@ -104,6 +109,10 @@ const askedWeather = params.get('weather') as Weather | null
 function timeNow(): TimeName {
   return asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time
 }
+// `?pace=olympic` asks for a pace whatever the settings say: taken up for this visit,
+// and kept only if the skater goes on to change it.
+const askedPace = params.get('pace') as Pace | null
+if (askedPace && PACE_NAMES.includes(askedPace)) settings.pace = askedPace
 /** And the weather, likewise: `?weather=snow` asks for one whatever the settings say. */
 function weatherNow(): Weather {
   if (askedWeather && WEATHERS.includes(askedWeather)) return askedWeather
@@ -113,6 +122,8 @@ showWorld()
 
 /** How far below level the skater looks, radians. */
 let pitch = 0
+/** The field of view at a cruising pace, degrees; it widens with speed. */
+let baseFov = 65
 
 function resize(): void {
   const { clientWidth: w, clientHeight: h } = canvas
@@ -120,7 +131,8 @@ function resize(): void {
   camera.aspect = w / h
   // The field of view is the vertical one. A phone held upright needs a wide one, or the
   // view across is so narrow that both shores fall outside it.
-  camera.fov = w < h ? 90 : 65
+  baseFov = w < h ? 90 : 65
+  camera.fov = baseFov
   // And held upright there is far more height than the lake needs: look further down, at
   // the ice coming, rather than fill the top half of the screen with sky.
   pitch = w < h ? 0.3 : 0.12
@@ -152,10 +164,23 @@ try {
 /** Whether the ice can be heard at this moment. */
 const sounding = (): boolean => audio?.state === 'running'
 
+/**
+ * A picture for a button, from line art that is black on nothing: only its shape is
+ * used, filled with whatever colour the button's words are, so that it is white on a
+ * dark button and dark on a white one (style.css).
+ */
+function icon(picture: string): HTMLSpanElement {
+  const span = document.createElement('span')
+  span.className = 'icon'
+  span.style.setProperty('--icon', `url(${picture})`)
+  return span
+}
+const soundIcon = soundButton.appendChild(icon(soundOffIcon))
+
 function showSound(): void {
   const on = audio !== null && !muted
   // The picture says which way it is set; the words are for whoever cannot see it.
-  soundButton.textContent = on ? '🔊' : '🔇'
+  soundIcon.style.setProperty('--icon', `url(${on ? soundOnIcon : soundOffIcon})`)
   soundButton.setAttribute('aria-label', on ? 'Sound on' : muted ? 'Sound off' : 'Tap for sound')
   soundButton.title = soundButton.getAttribute('aria-label')!
   soundButton.setAttribute('aria-pressed', String(on))
@@ -187,7 +212,7 @@ function setMuted(to: boolean): void {
 showSound()
 // A touch of the button must not also start the sound, since it has just been asked to
 // do the opposite; nor a touch of a link, which is someone on their way out.
-const aside = (e: Event): boolean => e.target === soundButton || links.contains(e.target as Node)
+const aside = (e: Event): boolean => soundButton.contains(e.target as Node) || links.contains(e.target as Node)
 
 // A box over the lake stops it while the box is open: the skater, and the sound with
 // them. The sky goes on, and the settings can be seen taking effect behind their box.
@@ -210,9 +235,37 @@ for (const [button, box] of [[aboutButton, about], [settingsButton, settingsBox]
 // The settings box: each thing in it takes effect as it is touched, and is remembered.
 const impossible = settingsBox.querySelector<HTMLInputElement>('#impossible')!
 const tiltButton = document.querySelector<HTMLButtonElement>('#tilt-button')!
+const paceButton = document.querySelector<HTMLButtonElement>('#pace-button')!
+/** A pace's name as it is shown, and its speed as people say speeds. */
+const paceName = (pace: Pace): string => pace[0]!.toUpperCase() + pace.slice(1)
+const paceSpeed = (pace: Pace): string => `${Math.round(PACES[pace].speed * 3.6)} km/h`
+/** Keep a pace: the skater works up to it, or eases down, over the next few strides. */
+function setPace(pace: Pace): void {
+  settings.pace = skater.pace = pace
+  saveSettings(settings)
+  showSettings()
+  // Said where the hint is, for long enough to read.
+  hint.textContent = `${paceName(pace)} · ${paceSpeed(pace)}`
+  hint.classList.remove('gone')
+  clearTimeout(paceSaid)
+  paceSaid = window.setTimeout(() => hint.classList.add('gone'), 2500)
+}
+let paceSaid = 0
+/** One pace faster or slower; `round` goes from the fastest back to the slowest. */
+function stepPace(by: number, round = false): void {
+  const at = PACE_NAMES.indexOf(settings.pace) + by
+  const to = round ? (at + PACE_NAMES.length) % PACE_NAMES.length : Math.max(0, Math.min(PACE_NAMES.length - 1, at))
+  if (PACE_NAMES[to] !== settings.pace) setPace(PACE_NAMES[to]!)
+}
+// The skate is the app's own icon, as on the browser's tab.
+paceButton.append(icon(skateIcon))
+const paceLabel = paceButton.appendChild(document.createElement('span'))
+paceButton.addEventListener('click', () => stepPace(1, true))
 // Steering by tilt is offered only on what can be tilted, by a button of its own at the
 // foot of the screen.
 tiltButton.hidden = !Input.canTilt
+const tiltIcon = tiltButton.appendChild(icon(tiltOffIcon))
+const tiltLabel = tiltButton.appendChild(document.createElement('span'))
 // A touch of the button is the touch an iPhone wants before it will give its leave.
 tiltButton.addEventListener('click', () => void setTilt(!settings.tilt))
 /** Whether the page was put into full screen in order to hold the screen's rotation. */
@@ -271,17 +324,21 @@ async function setTilt(on: boolean): Promise<void> {
 if (settings.tilt && Input.canTilt) {
   // (Unless that touch is of the Tilt button, which is someone turning it off.)
   if (Input.tiltNeedsTouch) {
-    window.addEventListener('pointerdown', (e) => void (e.target !== tiltButton && setTilt(true)), { once: true })
+    window.addEventListener('pointerdown', (e) => void (!tiltButton.contains(e.target as Node) && setTilt(true)), { once: true })
   }
   else void setTilt(true)
 }
 const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only')
 function showSettings(): void {
   for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
-    radio.checked = radio.value === settings[radio.name as 'time' | 'weather']
+    radio.checked = radio.value === settings[radio.name as 'time' | 'weather' | 'pace']
   }
   impossible.checked = settings.impossibleIce
-  tiltButton.textContent = settings.tilt ? '📱 Tilt on' : '📱 Tilt off'
+  paceLabel.textContent = paceName(settings.pace)
+  paceButton.setAttribute('aria-label', `Pace: ${paceName(settings.pace)}, ${paceSpeed(settings.pace)}. Press for the next.`)
+  // A phone in a hand when tilting it steers, and a phone by itself when it does not.
+  tiltIcon.style.setProperty('--icon', `url(${settings.tilt ? tiltOnIcon : tiltOffIcon})`)
+  tiltLabel.textContent = settings.tilt ? 'Tilt on' : 'Tilt off'
   tiltButton.setAttribute('aria-pressed', String(settings.tilt))
   tiltButton.classList.toggle('on', settings.tilt)
   hint.textContent = settings.tilt ? 'Tilt to steer — or just watch' : 'Drag left or right to steer — or just watch'
@@ -291,6 +348,7 @@ function showSettings(): void {
 showSettings()
 settingsBox.addEventListener('change', (e) => {
   const changed = e.target as HTMLInputElement
+  if (changed.name === 'pace') return setPace(changed.value as Pace)
   if (changed.name === 'time' || changed.name === 'weather') {
     ;(settings[changed.name] as string) = changed.value
     showWorld()
@@ -314,6 +372,7 @@ function enterLake(to: number): void {
   ice.show(lake)
   shores.show(lake, seed)
   skater = createSkater(lake)
+  skater.pace = settings.pace
   marks.clear(skater.x, skater.s)
   showWorld()
   showLake()
@@ -350,6 +409,8 @@ window.addEventListener('pointerdown', (e) => {
 window.addEventListener('keydown', (e) => {
   if (e.code === 'KeyM') setMuted(audio !== null && !muted)
   else if (e.code === 'KeyR' && !waiting() && !e.metaKey && !e.ctrlKey) randomize()
+  else if ((e.code === 'ArrowUp' || e.code === 'KeyW') && !waiting()) stepPace(1)
+  else if ((e.code === 'ArrowDown' || e.code === 'KeyS') && !waiting() && !e.metaKey && !e.ctrlKey) stepPace(-1)
   else if (!aside(e)) startSound()
 })
 // Frames stop while the page is hidden, but sound would not: the hum would drone on.
@@ -366,6 +427,7 @@ keepAwake()
 document.addEventListener('visibilitychange', keepAwake)
 window.addEventListener('pointerdown', keepAwake, { once: true })
 marks.clear(skater.x, skater.s)
+skater.pace = settings.pace
 /** Seconds until the ice next cracks somewhere off across the lake. */
 let untilCrack = 3
 
@@ -376,6 +438,9 @@ renderer.setAnimationLoop(() => {
   // and X or square turns the sound on and off.
   for (const button of input.pollPads()) {
     if (button === 3 && !waiting()) randomize()
+    // Up on the direction buttons or the right shoulder: faster. Down, or the left: slower.
+    else if (button === 12 || button === 5) stepPace(1)
+    else if (button === 13 || button === 4) stepPace(-1)
     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()
@@ -416,7 +481,10 @@ 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])
+  // The wind in the skater's ears is the weather's and their own: at a racing pace the
+  // air rushes past on the stillest day.
+  const rush = 0.5 * Math.min(1.2, skater.speed / PACES.olympic.speed) ** 2.5
+  if (sound && sounding()) sound.wind(Math.max(blowing / WEATHER.blizzard.wind[1], rush))
   if (sound && sounding() && skater.under === 0) {
     // One strike of a skate at the start of each stride, no two quite alike.
     // Snow under the blade: the strike is muffled by as much as there is of it, and the
@@ -444,10 +512,21 @@ renderer.setAnimationLoop(() => {
   // A skater's head is the stillest part of them: it does not swing from leg to leg.
   // It rises a little with each push, and leans into a turn as the body under it does,
   // by as much as the turn presses sideways.
-  const push = skater.under === 0 && sincePush(skater) < PUSH_SECONDS ? Math.sin((Math.PI * sincePush(skater)) / PUSH_SECONDS) : 0
+  const pushFor = pushSeconds(skater.pace)
+  const push = skater.under === 0 && sincePush(skater) < pushFor ? Math.sin((Math.PI * sincePush(skater)) / pushFor) : 0
+  // A racer is down in a crouch and an ambler stands tall; the eyes move between the
+  // two as the speed does. And the faster the skater, the wider the view, which is most
+  // of what makes speed look like speed.
+  const hurry = (skater.speed - PACES.cruise.speed) / (PACES.olympic.speed - PACES.cruise.speed)
+  const crouch = hurry > 0 ? PACES.olympic.crouch * hurry : PACES.meander.crouch * Math.min(1, -hurry * 2.3)
+  const fov = baseFov + 12 * Math.max(-0.4, Math.min(1.1, hurry))
+  if (Math.abs(camera.fov - fov) > 0.05) {
+    camera.fov = fov
+    camera.updateProjectionMatrix()
+  }
   // Going through the ice drops the view to the water in the first third of a second.
   const sunk = skater.under > 0 ? Math.min(1, (UNDER_SECONDS - skater.under) / 0.3) : 0
-  camera.position.y = EYE + 0.03 * push - 1.3 * sunk
+  camera.position.y = EYE - crouch + 0.03 * push - 1.3 * sunk
   // The eyes lead where the lake goes, and turn most of the way into the skater's own
   // direction: a skater cutting towards a shore still watches the lake ahead.
   const along = Math.atan(skater.channel.slope(skater.s))
diff --git a/src/settings.ts b/src/settings.ts
index 696d371..c01d8f0 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,4 +1,5 @@
 import { TIME_NAMES, type TimeName } from './render/sky.js'
+import { PACE_NAMES, type Pace } from './skater.js'
 import { mulberry32 } from './world/lake.js'
 
 /** The weathers a lake can have. */
@@ -22,13 +23,15 @@ export interface Settings {
   time: TimeName | 'surprise'
   /** The weather, or `surprise` for whichever the lake comes with. */
   weather: Weather | 'surprise'
+  /** The pace the skater keeps (skater.ts). */
+  pace: Pace
   /** 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', weather: 'surprise', impossibleIce: false, tilt: false }
+const DEFAULTS: Settings = { time: 'surprise', weather: 'surprise', pace: 'cruise', impossibleIce: false, tilt: false }
 const KEY = 'settings'
 
 export function loadSettings(): Settings {
@@ -37,6 +40,7 @@ export function loadSettings(): 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,
+      pace: PACE_NAMES.includes(kept.pace as Pace) ? kept.pace! : DEFAULTS.pace,
       impossibleIce: kept.impossibleIce === true,
       tilt: kept.tilt === true,
     }
diff --git a/src/skater.ts b/src/skater.ts
index 3d6c333..8a3c2a7 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -12,8 +12,28 @@ import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js'
  */
 export const RULES = { falling: false, carve: true }
 
-/** The speed the skater settles around, m/s: about 25 km/h, an easy pace on good ice. */
+/** The speed the skater settles around at an easy pace, m/s: about 25 km/h. */
 export const SPEED = 7
+
+/**
+ * The paces a skater can keep. `speed` is the speed they settle around, m/s. `strides`
+ * is how long their strides are against a cruising skater's: an ambler takes long,
+ * lazy ones, and a racer short, quick ones, down in a crouch (`crouch`: how much lower
+ * their eyes are, metres).
+ *
+ * Olympic is what it says: 15 m/s is 54 km/h, the pace of a long-track speed skater in
+ * a 1500 m, kept up for ever by someone who is in no hurry to get anywhere.
+ */
+export const PACES = {
+  meander: { speed: 3.5, strides: 1.4, crouch: -0.1 },
+  cruise: { speed: SPEED, strides: 1, crouch: 0 },
+  brisk: { speed: 10, strides: 0.75, crouch: 0.1 },
+  racing: { speed: 12.5, strides: 0.6, crouch: 0.22 },
+  olympic: { speed: 15, strides: 0.45, crouch: 0.35 },
+} as const
+export type Pace = keyof typeof PACES
+/** Slowest first. */
+export const PACE_NAMES = Object.keys(PACES) as Pace[]
 /**
  * A stride is a push and then a glide. The push lasts `PUSH_SECONDS` and speeds the
  * skater up by `PUSH` m/s²; all the while the ice and the air slow them, by `DRAG` m/s²
@@ -22,6 +42,8 @@ export const SPEED = 7
  * short ones faster, and the speed breathes with the stride.
  */
 export const PUSH_SECONDS = 0.5
+/** How long the push of a stride lasts at a pace, seconds: the same share of a shorter or longer stride. */
+export const pushSeconds = (pace: Pace): number => PUSH_SECONDS * PACES[pace].strides
 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
@@ -35,6 +57,12 @@ const PUSH = (DRAG * (MIN_STRIDE + MAX_STRIDE)) / 2 / PUSH_SECONDS
  * radius and a third of a g sideways: a firm carve on a long blade, not a hockey stop.
  */
 export const MAX_TURN = 0.45
+/**
+ * And the hardest they are pressed sideways by a turn, m/s²: half a g. At an easy pace
+ * the limit above is reached first; a racer is held to this one, and turns in a wider
+ * circle for it, 45 m at Olympic pace.
+ */
+const MAX_SIDEWAYS = 5
 /** Seconds for the turn to follow the steering: the weight of a body leaning over. */
 const TURN_LAG = 0.3
 /**
@@ -45,8 +73,8 @@ const TURN_LAG = 0.3
 export const MAX_HEADING = 0.87
 /** How close the skater may come to the edge of an islet, metres. */
 export const ISLET_MARGIN = 1.5
-/** How far ahead an islet in the way is steered round, metres, and by how much it is cleared. */
-const ISLET_AHEAD = 30
+/** How far ahead an islet in the way is steered round, in seconds of skating (and never less than 24 m), and by how much it is cleared, metres. */
+const ISLET_SECONDS = 4.3
 const ISLET_BERTH = 3.5
 
 /** The autopilot never lets the skater closer to a shore than this, metres. */
@@ -59,7 +87,7 @@ export const MAX_REACH = 150
 /** Within this of the edge of their room, metres, the skater is turned to run along it. */
 const EDGE = 8
 /** How fast a skater who has got past that edge is brought back to it, m/s. */
-const EDGE_PULL = 6
+const EDGE_PULL = 8
 /**
  * At a fork the skater changes channel once they are this much nearer the middle of the
  * other, metres: a little past half way, so that being a hair to one side as the two
@@ -67,8 +95,8 @@ const EDGE_PULL = 6
  */
 const CHANGE_BY = 3
 
-/** How far ahead of itself the autopilot aims, metres: about two and a half seconds. */
-const LOOK_AHEAD = 18
+/** How far ahead of itself the autopilot aims, in seconds of skating, and never less than 12 m. */
+const LOOK_SECONDS = 2.6
 /**
  * The autopilot's line swings from side to side of where it is centred: by `CARVE` metres
  * each way when it has been left to itself for a while, over about `CARVE_LENGTH` metres
@@ -85,9 +113,9 @@ const FORK_ASK = 200
 const ISLAND_ASK = 120
 const ISLAND_NEAR = 40
 
-/** How long stride number `index` lasts, seconds: the same every time it is asked. */
-export function strideSeconds(index: number): number {
-  return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()
+/** How long stride number `index` lasts at a pace, seconds: the same every time it is asked. */
+export function strideSeconds(index: number, pace: Pace = 'cruise'): number {
+  return (MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()) * PACES[pace].strides
 }
 
 /** Seconds between going through the ice and skating on from the middle of the channel. */
@@ -105,6 +133,8 @@ export interface Skater {
   turn: number
   /** m/s along the direction of travel. */
   speed: number
+  /** The pace they are keeping, or are working up or easing down to. */
+  pace: Pace
   /**
    * The autopilot's plan: a line that swings from side to side as it goes down the lake.
    * `hold` is where across the channel the line is centred, metres from the channel's
@@ -141,14 +171,14 @@ export interface Skater {
 export function createSkater(lake: Lake, s = 0): Skater {
   const channel = channelAt(lake, s)
   return {
-    x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED,
+    x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, pace: 'cruise',
     hold: 0, swing: 0, phase: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0,
   }
 }
 
 /** Seconds since the push that began the skater's current stride. */
 export function sincePush(k: Skater): number {
-  return (k.stride % 1) * strideSeconds(Math.floor(k.stride))
+  return (k.stride % 1) * strideSeconds(Math.floor(k.stride), k.pace)
 }
 
 /**
@@ -203,8 +233,8 @@ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.m
  * off, radians: not at all at the edge itself, `MAX_HEADING` a little way from it, and
  * straight at it from three times that far, which is as good as no limit.
  */
-const mayPoint = (room: number): number =>
-  clamp(room / EDGE, 0, 1) * MAX_HEADING + clamp((room - EDGE) / (2 * EDGE), 0, 1) * (Math.PI / 2 - MAX_HEADING)
+const mayPoint = (room: number, edge: number): number =>
+  clamp(room / edge, 0, 1) * MAX_HEADING + clamp((room - edge) / (2 * edge), 0, 1) * (Math.PI / 2 - MAX_HEADING)
 
 /** An angle as the same angle between a half turn left and a half turn right. */
 const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle / (2 * Math.PI))
@@ -256,24 +286,35 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   }
   const { channel } = k
   const along = Math.atan(channel.slope(k.s))
+  // What the skater's speed makes of the limits and the distances: a faster skater
+  // turns less sharply, looks further ahead, and keeps further from the edges.
+  const pace = PACES[k.pace]
+  const maxTurn = Math.min(MAX_TURN, MAX_SIDEWAYS / Math.max(k.speed, 1))
+  const lookAhead = Math.max(12, LOOK_SECONDS * k.speed)
+  const isletAhead = Math.max(24, ISLET_SECONDS * k.speed)
+  // (The room it takes to turn away from an edge goes up faster than the speed does.)
+  const edge = clamp(EDGE * (k.speed / SPEED) ** 1.5, 6, 28)
+  const edgePull = EDGE_PULL * Math.max(1, k.speed / SPEED)
+  const islandNear = (ISLAND_NEAR * Math.max(k.speed, SPEED)) / SPEED
   // Which side of an island: alongside one, the side the skater is on; coming up to
   // one, the side they asked for or are over to, or the island's own choice.
   const off = k.x - channel.centre(k.s)
   if (channel.island(k.s) > 0) k.side = Math.sign(off) || k.side || 1
-  else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + ISLAND_NEAR) > 0) {
+  else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + islandNear) > 0) {
     if (Math.abs(steer) > 0.05) k.side = Math.sign(steer)
-    else if (k.side === 0 && channel.island(k.s + ISLAND_NEAR) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + ISLAND_NEAR)
+    else if (k.side === 0 && channel.island(k.s + islandNear) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + islandNear)
   } else k.side = 0
   const [left, right] = room(k, k.s)
 
   // What turn is wanted: the steering's, or the autopilot's.
   // How fast the line swings, radians for each metre of lake: not quite regularly, so
   // that no two swings are the same length.
-  const rate = (2 * Math.PI) / (CARVE_LENGTH * (1 + 0.3 * lake.carve(k.s)))
+  // A faster skater's swings are longer, though not in proportion: more of them go by in a minute.
+  const rate = (2 * Math.PI) / (CARVE_LENGTH * (pace.speed / SPEED) ** 0.7 * (1 + 0.3 * lake.carve(k.s)))
   let wanted: number
   if (Math.abs(steer) > 0.05) {
     k.idle = 0
-    wanted = clamp(steer, -1, 1) * MAX_TURN
+    wanted = clamp(steer, -1, 1) * maxTurn
     // Coming up to a fork, or between its two channels, this is asking for a side of it.
     const side = alongside(k)
     if (k.s > channel.end.s - FORK_ASK) k.wish = Math.sign(steer)
@@ -292,7 +333,7 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
     k.phase = angle >= 0 ? 0 : Math.PI
   } else {
     k.idle += dt
-    const ahead = k.s + LOOK_AHEAD
+    const ahead = k.s + lookAhead
     // On along the line, by as much of the lake as has gone by.
     k.phase += rate * k.speed * Math.cos(k.heading - along) * dt
     // The swing settles to its usual size, and to nothing through a fork, from a little
@@ -303,8 +344,8 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
     k.swing += (usual - k.swing) * (1 - Math.exp(-dt / (open ? SETTLE : 3)))
     const [farLeft, farRight] = room(k, ahead)
     // Aimed clear of the edges, or down the middle where there is not the room for that.
-    const clear = Math.min(EDGE, (farRight - farLeft) / 2)
-    const swung = k.swing * Math.sin(k.phase + rate * LOOK_AHEAD)
+    const clear = Math.min(edge, (farRight - farLeft) / 2)
+    const swung = k.swing * Math.sin(k.phase + rate * lookAhead)
     // Past an island the line is down the middle of the water on its side of it, and
     // stays where that leaves it afterwards.
     if (channel.island(ahead) > 0) k.hold = (farLeft + farRight) / 2 - channel.centre(ahead)
@@ -313,7 +354,7 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
     const most = Math.max(0, reach(channel, ahead) - clear)
     k.hold = clamp(k.hold, -most, most)
     const aim = clamp(channel.centre(ahead) + k.hold + swung, farLeft + clear, farRight - clear)
-    wanted = clamp(wrap(Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN)
+    wanted = clamp(wrap(Math.atan2(aim - k.x, lookAhead) - k.heading) / 0.8, -maxTurn, maxTurn)
   }
 
   // Which channel the line is now measured from, should the skater be in another by the
@@ -325,16 +366,16 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   // The autopilot makes that turn as soon as it is one; a skater who is steering is left
   // to it until the turn needed is a sharp one.
   const ahead = [Math.sin(k.heading), Math.cos(k.heading)] as const
-  let sharpest = Math.abs(steer) > 0.05 ? 0.6 * MAX_TURN : 0
-  for (const islet of channel.islets(k.s - ISLET_AHEAD - 10, k.s + ISLET_AHEAD + 10)) {
+  let sharpest = Math.abs(steer) > 0.05 ? 0.6 * maxTurn : 0
+  for (const islet of channel.islets(k.s - isletAhead - 10, k.s + isletAhead + 10)) {
     const dx = islet.x - k.x
     const ds = islet.s - k.s
     const along = dx * ahead[0] + ds * ahead[1]
     // How far to the right of the skater's line its middle is.
     const aside = dx * ahead[1] - ds * ahead[0]
     const short = islet.radius + ISLET_BERTH - Math.abs(aside)
-    if (along < 0 || along > ISLET_AHEAD + islet.radius || short <= 0) continue
-    const needed = Math.min(2 * MAX_TURN, (2.6 * short * k.speed) / Math.max(along - islet.radius, 2) ** 2)
+    if (along < 0 || along > isletAhead + islet.radius || short <= 0) continue
+    const needed = Math.min(2 * maxTurn, (2.6 * short * k.speed) / Math.max(along - islet.radius, 2) ** 2)
     if (needed > sharpest) {
       sharpest = needed
       // Round whichever side of it the skater is already on.
@@ -349,25 +390,29 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   const out = wrap(k.heading - along)
   const towards = Math.sin(out)
   const away = Math.cos(out) >= 0 ? 1 : -1
-  const mayRight = Math.sin(mayPoint(right - k.x))
-  const mayLeft = Math.sin(mayPoint(k.x - left))
-  if (towards > mayRight) wanted = -away * clamp((towards - mayRight) / 0.2, Math.max(0, -away * wanted), 2 * MAX_TURN)
-  if (towards < -mayLeft) wanted = away * clamp((-mayLeft - towards) / 0.2, Math.max(0, away * wanted), 2 * MAX_TURN)
+  const mayRight = Math.sin(mayPoint(right - k.x, edge))
+  const mayLeft = Math.sin(mayPoint(k.x - left, edge))
+  if (towards > mayRight) wanted = -away * clamp((towards - mayRight) / 0.2, Math.max(0, -away * wanted), 2 * maxTurn)
+  if (towards < -mayLeft) wanted = away * clamp((-mayLeft - towards) / 0.2, Math.max(0, away * wanted), 2 * maxTurn)
 
   k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG))
   k.heading += k.turn * dt
 
-  const pushing = sincePush(k) < PUSH_SECONDS
+  const pushing = sincePush(k) < pushSeconds(k.pace)
   // 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
+  // The push and the drag are balanced at the pace's own speed, whatever it is, and
+  // scaled so that the speed breathes by the same share of it at every pace. A skater
+  // who changes pace works up to the new one, or eases down to it, over a few strides.
+  const effort = pace.speed / SPEED / pace.strides
+  k.speed += effort * ((pushing ? PUSH : 0) - drag * (k.speed / pace.speed) ** 2) * dt
 
   // Which channel the skater is in can change; where they are on the ice does not.
   const was = alongside(k)
   k.s += k.speed * Math.cos(k.heading) * dt
   k.x += k.speed * Math.sin(k.heading) * dt
-  k.stride += dt / strideSeconds(Math.floor(k.stride))
+  k.stride += dt / strideSeconds(Math.floor(k.stride), k.pace)
   if (k.s >= k.channel.end.s) {
     const fork = k.channel.end
     k.channel = k.wish < 0 ? fork.left : k.wish > 0 ? fork.right : fork.preferred
@@ -386,18 +431,18 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   const [edgeLeft, edgeRight] = room(k, k.s)
   if (k.x < edgeLeft || k.x > edgeRight) {
     const over = k.x < edgeLeft
-    k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -EDGE_PULL * dt, EDGE_PULL * dt)
+    k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -edgePull * dt, edgePull * dt)
     // Turned along it, the nearer way round, as fast as a skater can be turned.
     const off = wrap(k.heading - Math.atan(k.channel.slope(k.s)))
     const pointing = Math.sin(off) * (over ? -1 : 1)
-    if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * MAX_TURN * dt, Math.abs(off))
+    if (pointing > 0) k.heading += (over ? 1 : -1) * (Math.cos(off) >= 0 ? 1 : -1) * Math.min(2 * maxTurn * dt, Math.abs(off))
   }
   // And off the islets: a skater who has got inside the margin of one is moved out of it.
   for (const islet of k.channel.islets(k.s - 20, k.s + 20)) {
     const far = Math.hypot(k.x - islet.x, k.s - islet.s)
     const inside = islet.radius + ISLET_MARGIN - far
     if (inside <= 0 || far === 0) continue
-    const push = Math.min(inside, EDGE_PULL * dt) / far
+    const push = Math.min(inside, edgePull * dt) / far
     k.x += (k.x - islet.x) * push
     k.s += (k.s - islet.s) * push
   }
diff --git a/src/style.css b/src/style.css
index 57a44b9..2f81ea1 100644
--- a/src/style.css
+++ b/src/style.css
@@ -269,3 +269,40 @@ dialog button.done {
 #tilt-button:not([hidden]) ~ #hint {
   bottom: calc(4.5rem + env(safe-area-inset-bottom));
 }
+
+/* The pace, and the way to the next one: opposite the tilt button. */
+#pace-button {
+  position: fixed;
+  right: calc(0.75rem + env(safe-area-inset-right));
+  bottom: calc(0.75rem + env(safe-area-inset-bottom));
+  min-height: 2.75rem;
+  padding: 0 0.9rem;
+  border: 2px solid #fff;
+  border-radius: 2rem;
+  background: rgb(8 24 34 / 85%);
+  color: #fff;
+  font: 600 0.95rem/1 system-ui, sans-serif;
+  touch-action: manipulation;
+  cursor: pointer;
+}
+
+/* The hint keeps clear of the buttons at the foot of the screen. */
+#hint {
+  bottom: calc(4.5rem + env(safe-area-inset-bottom));
+}
+
+/* A picture on a button: the shape of a piece of line art, in the colour of the button's words. */
+.icon {
+  display: inline-block;
+  width: 1.4em;
+  height: 1.4em;
+  vertical-align: -0.35em;
+  background: currentColor;
+  -webkit-mask: var(--icon) center / contain no-repeat;
+  mask: var(--icon) center / contain no-repeat;
+}
+
+#pace-button .icon,
+#tilt-button .icon {
+  margin-right: 0.45em;
+}
diff --git a/test/settings.test.ts b/test/settings.test.ts
index 55bbeca..9209fb5 100644
--- a/test/settings.test.ts
+++ b/test/settings.test.ts
@@ -5,7 +5,7 @@ import { WEATHER, WEATHERS, anyLake, loadSettings, timeOf, weatherOf, windOf } f
 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', weather: 'surprise', impossibleIce: false, tilt: false })
+    expect(loadSettings()).toEqual({ time: 'surprise', weather: 'surprise', pace: 'cruise', impossibleIce: false, tilt: false })
   })
 
   it('picks a lake from a million', () => {
diff --git a/test/skater.test.ts b/test/skater.test.ts
index b3aa9be..b6def37 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
 import { BREAKING_THICKNESS } from '../src/ice/strength.js'
 import {
   ISLET_MARGIN, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS,
-  RULES, type Skater, createSkater, stepSkater, strideSeconds,
+  PACES, PACE_NAMES, RULES, type Skater, createSkater, stepSkater, strideSeconds,
 } from '../src/skater.js'
 import { type Lake, createLake, mulberry32 } from '../src/world/lake.js'
 
@@ -143,7 +143,7 @@ describe('skater', () => {
       for (let t = 0; t < 1200; t += DT) {
         stepSkater(k, lake, steer(t), DT, STRAIGHT)
         if (alone(k)) {
-          expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.5)
+          expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.3)
         }
         nearest = Math.min(nearest, clearOfIslets(k))
         expect(clearOfIsland(k)).toBeGreaterThan(0.5)
@@ -310,6 +310,74 @@ describe('skater', () => {
     expect(k.speed).toBeGreaterThan(SPEED * 0.85)
   })
 
+  it('keeps each pace: settles round its speed, in strides to match, and breathes by the same share', () => {
+    for (const pace of PACE_NAMES) {
+      const k = createSkater(lake, 3300)
+      k.pace = pace
+      run(k, 0, 40)
+      let sum = 0
+      let slowest = Infinity
+      let fastest = 0
+      const strides = k.stride
+      const steps = Math.round(120 / DT)
+      for (let i = 0; i < steps; i++) {
+        stepSkater(k, lake, 0, DT, STRAIGHT)
+        sum += k.speed
+        slowest = Math.min(slowest, k.speed)
+        fastest = Math.max(fastest, k.speed)
+      }
+      const wanted = PACES[pace].speed
+      expect(sum / steps / wanted).toBeGreaterThan(0.93)
+      expect(sum / steps / wanted).toBeLessThan(1.07)
+      // Between a tenth and a third of its speed between the slowest and fastest of a stride.
+      expect((fastest - slowest) / wanted).toBeGreaterThan(0.1)
+      expect((fastest - slowest) / wanted).toBeLessThan(0.35)
+      // Strides as long as the pace takes them: about 2.3 s at a cruise.
+      expect(120 / (k.stride - strides) / PACES[pace].strides).toBeCloseTo(2.3, 0)
+    }
+  })
+
+  it('works up to a faster pace and eases down to a slower one over a few strides', () => {
+    const k = createSkater(lake, 3300)
+    k.pace = 'olympic'
+    run(k, 0, 2)
+    // Not at once.
+    expect(k.speed).toBeLessThan(13)
+    run(k, 0, 25)
+    expect(k.speed).toBeGreaterThan(13)
+    k.pace = 'meander'
+    run(k, 0, 3)
+    expect(k.speed).toBeGreaterThan(5)
+    run(k, 0, 40)
+    expect(k.speed).toBeLessThan(4.5)
+  })
+
+  it('at a racing pace turns in a wider circle, and still keeps to the ice and clear of everything', () => {
+    const k = createSkater(lake)
+    k.pace = 'olympic'
+    const steer = fidget(11)
+    let hardest = 0
+    for (let t = 0; t < 1200; t += DT) {
+      stepSkater(k, lake, steer(t), DT, RULES)
+      // Never pressed sideways much harder than half a g, turning away from an edge excepted.
+      hardest = Math.max(hardest, Math.abs(k.turn) * k.speed)
+      if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.3)
+      expect(clearOfIslets(k)).toBeGreaterThan(0)
+      expect(clearOfIsland(k)).toBeGreaterThan(0.3)
+    }
+    expect(hardest).toBeLessThan(11)
+    const auto = createSkater(lake)
+    auto.pace = 'olympic'
+    for (let t = 0; t < 1200; t += DT) {
+      stepSkater(auto, lake, 0, DT, RULES)
+      if (alone(auto)) expect(Math.abs(off(auto))).toBeLessThan(auto.channel.halfWidth(auto.s) - 0.5)
+      expect(clearOfIslets(auto)).toBeGreaterThan(0.5)
+      expect(clearOfIsland(auto)).toBeGreaterThan(0.5)
+    }
+    // Twenty minutes at that pace is the better part of eighteen kilometres.
+    expect(auto.s).toBeGreaterThan(15 * 1200 * 0.85)
+  })
+
   it('skates the same far down the lake as at its start', () => {
     const k = createSkater(lake, 1e6)
     run(k, 0, 10)
diff --git a/tools/icons.mjs b/tools/icons.mjs
index cc7b9b5..4248769 100644
--- a/tools/icons.mjs
+++ b/tools/icons.mjs
@@ -5,7 +5,8 @@
  *
  *   npm run icons
  *
- * The artwork is a skate by Icongeek26, from Flaticon, used under the Flaticon free
+ * The artwork is a skate by Icongeek26, from Flaticon, as are the pictures on the app's
+ * buttons (assets/), all used under the Flaticon free
  * licence, which requires attribution (README, Attribution). It is black line on nothing;
  * only its shape is used. On a home screen it is drawn pale on the colour of black ice.
  * On a browser's tab it is as it was drawn: black, on nothing, so that the tab shows
diff --git a/vite.config.ts b/vite.config.ts
index 5d98529..3837e2c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -41,7 +41,9 @@ export default defineConfig({
           { src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
         ],
       },
-      workbox: { cleanupOutdatedCaches: true },
+      // The pictures on the buttons are kept on the phone with the rest: without them
+      // named here they would be fetched, and missing with no network.
+      workbox: { cleanupOutdatedCaches: true, globPatterns: ['**/*.{js,css,html,png}'] },
       // Not while developing: a service worker handing back yesterday's code is the last
       // thing wanted there.
       devOptions: { enabled: false },