fuzzyworld

nordic-skating / commit

chore: photograph the app at phone size, and bring the README up to step 2

e5499331ee9b48444015713f29dde11108ed0ed3 · Pavel Safronov · 2026-10-08 23:21:11 -0700 · parents 8b9467caeda2

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

chore: photograph the app at phone size, and bring the README up to step 2

npm run screenshots builds the app and photographs it into
docs/screenshots/: on autopilot, steering, in landscape, and 10,000 km
down the lake. Playwright is pinned to 1.63.0, whose Chromium is the one
already installed here.

Usage: 206 in / 110.0K out / 7.1M cached (session tally)
rtk:   1.8M saved, 25.6% over 2806 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                      |  40 ++++++++++++++++++++++++++++---
 docs/screenshots/autopilot.png | Bin 0 -> 312341 bytes
 docs/screenshots/far.png       | Bin 0 -> 252696 bytes
 docs/screenshots/landscape.png | Bin 0 -> 435186 bytes
 docs/screenshots/steering.png  | Bin 0 -> 354089 bytes
 package-lock.json              |  30 ++++++++++++++++++++++++
 package.json                   |   4 +++-
 tools/screenshots.mjs          |  52 +++++++++++++++++++++++++++++++++++++++++
 8 files changed, 122 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 007a210..7fd1577 100644
--- a/README.md
+++ b/README.md
@@ -11,18 +11,46 @@ Inspired by National Geographic's
 [Hear the Otherworldly Sounds of Skating on Thin Ice](https://www.youtube.com/watch?v=v3O9vNi-dkA),
 in which Mårten Ajne skates a small lake outside Stockholm.
 
+<img src="docs/screenshots/landscape.png" alt="Black ice laced with white cracks, running between snowy banks of spruce to a foggy horizon" width="640" />
+
 ## Status
 
-**Step 1 of 6: the scaffold.** The page shows a still view across black ice to the
-horizon. Nothing moves and nothing sounds yet.
+**Step 2 of 6: an endless lake.** The lake runs on for ever between snowy, wooded
+shores, and the skater skates it. There is no sound yet.
 
 1. ✅ Scaffold: build, tests, deploy to GitLab Pages.
-2. An endless lake, skated on autopilot, with steering.
+2. ✅ An endless lake, skated on autopilot, with steering.
 3. Measure the reference video, then synthesize the ice sound from ice thickness and speed.
 4. Ice zones that fall out of the physics, and falling through.
 5. Forks in the lake.
 6. Game controller, then multiplayer.
 
+## Controls
+
+Do nothing and the skater follows the middle of the lake, round every bend, for as
+long as you leave it.
+
+| To | Touch or mouse | Keyboard |
+|---|---|---|
+| Steer left or right | Drag sideways from wherever you touch | ← → or A D |
+
+Steering moves the skater across the lake, never into a shore. Let go and the skater
+holds that line for a second and a half, then eases back to the middle.
+
+## How the lake works
+
+- **The lake is a function, not a map** (`src/world/lake.ts`). Where its middle is, how
+  wide it is and how the land rises from its shores are all computed from the distance
+  skated and a seed. Any stretch can be made on demand, the same on every device, and
+  nothing is stored.
+- **The skater is an offset from the middle of the lake** (`src/skater.ts`), not a
+  position. An offset nobody touches follows every bend, which is all the autopilot is.
+- **The skater never leaves the origin of what is drawn.** The shores are built 100 m at
+  a time ahead of the skater, thrown away behind, and placed relative to the skater
+  every frame (`src/render/shores.ts`); the ice is one sheet whose texture slides the
+  other way (`src/render/ice.ts`). So nothing drawn is ever a large number, and the
+  picture is as sharp 10,000 km down the lake as at the start.
+
 ## The sound
 
 The sound will be synthesized in the browser, not played back from a recording. Two
@@ -47,8 +75,13 @@ npm run dev      # dev server on every interface, with a QR code for a phone on
 npm test         # vitest
 npm run build    # typecheck, then build into dist/
 npm run preview  # serve dist/
+npm run screenshots  # build, then photograph the app at phone size into docs/screenshots/
 ```
 
+`?s=2500` on the URL starts that many metres down the lake.
+
+The screenshots need Playwright's Chromium once: `npx playwright install chromium`.
+
 ## Deploy
 
 `.gitlab-ci.yml` tests and builds every push, and publishes the default branch to
@@ -61,6 +94,7 @@ named `public`, and a `public/` already in the repository would collide with it.
 ## Attribution
 
 - [three.js](https://threejs.org/) — MIT licence.
+- [simplex-noise](https://github.com/jwagner/simplex-noise.js) — MIT licence.
 - The National Geographic video above is the reference this is modelled on. It is
   copyrighted, and neither it nor any audio taken from it is part of this repository
   or the app: it is measured locally, and the app's sound is synthesized from those
diff --git a/docs/screenshots/autopilot.png b/docs/screenshots/autopilot.png
new file mode 100644
index 0000000..2dd785c
Binary files /dev/null and b/docs/screenshots/autopilot.png differ
diff --git a/docs/screenshots/far.png b/docs/screenshots/far.png
new file mode 100644
index 0000000..d04c85f
Binary files /dev/null and b/docs/screenshots/far.png differ
diff --git a/docs/screenshots/landscape.png b/docs/screenshots/landscape.png
new file mode 100644
index 0000000..69cc56c
Binary files /dev/null and b/docs/screenshots/landscape.png differ
diff --git a/docs/screenshots/steering.png b/docs/screenshots/steering.png
new file mode 100644
index 0000000..2884cca
Binary files /dev/null and b/docs/screenshots/steering.png differ
diff --git a/package-lock.json b/package-lock.json
index 436de3d..a5cca82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
       "devDependencies": {
         "@types/node": "^26.4.1",
         "@types/three": "^0.180.0",
+        "playwright": "1.63.0",
         "typescript": "^7.0.2",
         "vite": "^8.2.2",
         "vite-plugin-qrcode": "^0.4.1",
@@ -1212,6 +1213,35 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/playwright": {
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+      "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.63.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+      "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.29",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
diff --git a/package.json b/package.json
index c0644e4..81b4a1a 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
     "build": "tsc --noEmit && vite build",
     "preview": "vite preview",
     "test": "vitest run --passWithNoTests",
-    "test:watch": "vitest"
+    "test:watch": "vitest",
+    "screenshots": "npm run build && node tools/screenshots.mjs"
   },
   "dependencies": {
     "simplex-noise": "^4.0.3",
@@ -17,6 +18,7 @@
   "devDependencies": {
     "@types/node": "^26.4.1",
     "@types/three": "^0.180.0",
+    "playwright": "1.63.0",
     "typescript": "^7.0.2",
     "vite": "^8.2.2",
     "vite-plugin-qrcode": "^0.4.1",
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
new file mode 100644
index 0000000..d6b618b
--- /dev/null
+++ b/tools/screenshots.mjs
@@ -0,0 +1,52 @@
+/**
+ * Photograph the built app at phone size into docs/screenshots/, for the README and for
+ * looking a change over before it is committed.
+ *
+ *   npm run screenshots
+ */
+import { mkdirSync } from 'node:fs'
+import { chromium } from 'playwright'
+import { preview } from 'vite'
+
+const OUT = 'docs/screenshots'
+const PHONE = { width: 390, height: 844 }
+
+/** What to photograph: where on the lake, and what to do there first. */
+const SHOTS = [
+  { name: 'autopilot', query: '?s=400', act: async () => {} },
+  {
+    name: 'steering',
+    query: '?s=2600',
+    act: async (page) => {
+      await page.keyboard.down('ArrowRight')
+      await page.waitForTimeout(1500)
+    },
+  },
+  { name: 'landscape', query: '?s=5200', viewport: { width: 844, height: 390 }, act: async () => {} },
+  // Ten thousand kilometres down the lake: the picture must be as sharp as at the start.
+  { name: 'far', query: '?s=10000000', act: async () => {} },
+]
+
+mkdirSync(OUT, { recursive: true })
+const server = await preview({ preview: { port: 4317, strictPort: true } })
+const base = server.resolvedUrls.local[0]
+// The full Chrome for Testing build reaches the GPU; the headless shell draws in software.
+const browser = await chromium.launch({ channel: 'chromium' }).catch(() => chromium.launch())
+try {
+  for (const shot of SHOTS) {
+    const page = await browser.newPage({ viewport: shot.viewport ?? PHONE, deviceScaleFactor: 2 })
+    const errors = []
+    page.on('pageerror', (e) => errors.push(e.message))
+    page.on('console', (m) => m.type() === 'error' && errors.push(m.text()))
+    await page.goto(base + shot.query, { waitUntil: 'networkidle' })
+    await page.waitForTimeout(1500)
+    await shot.act(page)
+    await page.screenshot({ path: `${OUT}/${shot.name}.png` })
+    await page.close()
+    if (errors.length) throw new Error(`${shot.name}: ${errors.join('; ')}`)
+    console.log(`  ${OUT}/${shot.name}.png`)
+  }
+} finally {
+  await browser.close()
+  await server.close()
+}