fuzzyworld

nordic-skating / commit

feat: tilt the phone to steer

8876a48d98c42d5cb9e3d56b18d1404740ad9d53 · Pavel Safronov · 2026-10-09 22:36:27 -0700 · parents df2b82155b20

Claude Code usage: this commit ~20.3K out / ~5.7M cached · cost unknown (no per-model trailer) · session 1 tally 914 in / 783.4K out / 104.4M cached · usage by commit and session

feat: tilt the phone to steer

A setting, off by default and shown only on phones and tablets. The
phone is leaned like a wheel: within 4 degrees of level is level, and
the skater skates itself; 28 degrees is full steering. It works held
upright or on its side, and dragging still works alongside it.

An iPhone asks leave for this at the first touch of each visit; refused,
the setting unticks itself and says where to turn it back on.

Not yet tried on a phone: browsers give a page the phone's tilt only
over HTTPS, which the dev server is not, so this goes to the live site
to be tried there.

Usage: 914 in / 783.4K out / 104.4M cached (session tally)
rtk:   1.7M saved, 25.3% over 2678 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 index.html            |  8 ++++++-
 src/input.ts          | 66 +++++++++++++++++++++++++++++++++++++++++++++++++--
 src/main.ts           | 25 +++++++++++++++++++
 src/settings.ts       |  5 +++-
 test/input.test.ts    | 31 ++++++++++++++++++++++++
 test/settings.test.ts |  4 ++--
 6 files changed, 133 insertions(+), 6 deletions(-)

diff --git a/index.html b/index.html
index 89fa5ed..d32e685 100644
--- a/index.html
+++ b/index.html
@@ -31,7 +31,7 @@
   <h2>Controls</h2>
   <dl>
     <dt>Steer</dt>
-    <dd>Drag left or right from wherever you touch, or use ← → or A D. Let go and the skater straightens up, then drifts back to its own line. Hold it over and the skater turns right round: there is room for a circle wherever the water is wide.</dd>
+    <dd>Drag left or right from wherever you touch, or use ← → or A D; or, on a phone, turn on Tilt to steer in Settings and lean it. Let go and the skater straightens up, then drifts back to its own line. Hold it over and the skater turns right round: there is room for a circle wherever the water is wide.</dd>
     <dt>Choose a way at a fork</dt>
     <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>
@@ -79,6 +79,12 @@
     <label><input type="radio" name="time" value="aurora" /> Aurora <small>the northern lights</small></label>
   </fieldset>
 
+  <fieldset id="steering" hidden>
+    <legend>Steering</legend>
+    <label><input type="checkbox" id="tilt" /> Tilt to steer <small>lean the phone left or right, like a wheel; held level, the skater skates itself</small></label>
+    <p id="tilt-refused" hidden><small>The browser would not allow it. On an iPhone: Settings, Safari, Motion &amp; Orientation Access.</small></p>
+  </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/input.ts b/src/input.ts
index fca27bb..b135b10 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -1,15 +1,77 @@
 /** How far a finger has to be dragged sideways for full steering, CSS pixels. */
 const FULL_DRAG = 70
 
+/**
+ * Tilting the phone to steer: how far over, in degrees, counts as nothing, since nobody
+ * holds a phone dead level, and how far is full steering.
+ */
+const TILT_REST = 4
+const TILT_FULL = 28
+
+/**
+ * Steering from how a phone is held: -1 (hard left) to 1 (hard right), 0 when it is
+ * level from side to side. The phone is turned like a wheel, about the line the skater
+ * looks along.
+ *
+ * `beta` and `gamma` are what the phone reports, in degrees: how far it is tipped top
+ * edge up, and how far it is rolled right edge down, both as if it were held upright.
+ * `turned` is how the screen is turned from upright, in degrees, as `screen.orientation`
+ * has it: held on its side, the roll that steers is what the phone calls its tip.
+ */
+export function tiltSteer(beta: number, gamma: number, turned: number): number {
+  const roll = turned === 90 ? beta : turned === 270 ? -beta : turned === 180 ? -gamma : gamma
+  const past = Math.abs(roll) - TILT_REST
+  return past <= 0 ? 0 : Math.sign(roll) * Math.min(1, past / (TILT_FULL - TILT_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, or the arrow keys or A and D.
+ * went down, the arrow keys or A and D, 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 readonly keys = new Set<string>()
+  private readonly tilted = (e: DeviceOrientationEvent): void => {
+    if (e.beta === null || e.gamma === null) return
+    // Eased a little: a hand is never still, and the reports come many times a second.
+    this.tilt += 0.3 * (tiltSteer(e.beta, e.gamma, screen.orientation?.angle ?? 0) - this.tilt)
+  }
+
+  /** Whether this is a device that can be steered by tilting it: a phone or a tablet. */
+  static get canTilt(): boolean {
+    return 'DeviceOrientationEvent' in window && matchMedia('(pointer: coarse)').matches
+  }
+
+  /** Whether turning tilt on has to wait for a touch, as it does on an iPhone. */
+  static get tiltNeedsTouch(): boolean {
+    return typeof (window.DeviceOrientationEvent as Asking | undefined)?.requestPermission === 'function'
+  }
+
+  /**
+   * Start steering by tilt. On an iPhone this asks the skater's leave, which the browser
+   * allows only from within a touch. Answers whether it was given.
+   */
+  async tiltOn(): Promise<boolean> {
+    try {
+      const ask = (window.DeviceOrientationEvent as Asking | undefined)?.requestPermission
+      if (ask && (await ask()) !== 'granted') return false
+    } catch {
+      return false
+    }
+    window.addEventListener('deviceorientation', this.tilted)
+    return true
+  }
+
+  tiltOff(): void {
+    window.removeEventListener('deviceorientation', this.tilted)
+    this.tilt = 0
+  }
 
   constructor(target: HTMLElement) {
     target.addEventListener('pointerdown', (e) => {
@@ -39,6 +101,6 @@ 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))
+    return Math.max(-1, Math.min(1, keys + this.drag + this.tilt))
   }
 }
diff --git a/src/main.ts b/src/main.ts
index 78e7aa6..2f02cc3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -180,12 +180,33 @@ 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 tilt = settingsBox.querySelector<HTMLInputElement>('#tilt')!
+const tiltRefused = settingsBox.querySelector<HTMLElement>('#tilt-refused')!
+// Steering by tilt is offered only on what can be tilted.
+settingsBox.querySelector<HTMLElement>('#steering')!.hidden = !Input.canTilt
+/** Turn steering by tilt on or off, and say so if the browser will not have it. */
+async function setTilt(on: boolean): Promise<void> {
+  const given = on && (await input.tiltOn())
+  if (!given) input.tiltOff()
+  tiltRefused.hidden = !on || given
+  settings.tilt = given
+  saveSettings(settings)
+  showSettings()
+}
+// Remembered from last time. An iPhone gives its leave afresh each visit and only at a
+// touch, so there it is asked for at the first one.
+if (settings.tilt && Input.canTilt) {
+  if (Input.tiltNeedsTouch) window.addEventListener('pointerdown', () => void 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[name=time]')) {
     radio.checked = radio.value === settings.time
   }
   impossible.checked = settings.impossibleIce
+  tilt.checked = settings.tilt
+  hint.textContent = settings.tilt ? 'Tilt to steer — or just watch' : 'Drag left or right to steer — or just watch'
   // What the About box says of impossible ice is there only when the ice is.
   for (const part of impossibleOnly) part.hidden = !settings.impossibleIce
 }
@@ -195,6 +216,10 @@ settingsBox.addEventListener('change', (e) => {
   if (changed.name === 'time') {
     settings.time = changed.value as typeof settings.time
     showTime(timeNow())
+  } else if (changed === tilt) {
+    // This is the touch an iPhone wants before it will give its leave.
+    void setTilt(tilt.checked)
+    return
   } else if (changed === impossible) {
     settings.impossibleIce = lakeOptions.impossibleIce = impossible.checked
     ice.redraw()
diff --git a/src/settings.ts b/src/settings.ts
index 79084d6..71bc76f 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -7,9 +7,11 @@ export interface Settings {
   time: TimeName | '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 }
+const DEFAULTS: Settings = { time: 'surprise', impossibleIce: false, tilt: false }
 const KEY = 'settings'
 
 export function loadSettings(): Settings {
@@ -18,6 +20,7 @@ export function loadSettings(): Settings {
     return {
       time: kept.time === 'surprise' || TIME_NAMES.includes(kept.time as TimeName) ? kept.time! : DEFAULTS.time,
       impossibleIce: kept.impossibleIce === true,
+      tilt: kept.tilt === true,
     }
   } catch {
     // No storage, or nonsense in it: the settings last for this visit only.
diff --git a/test/input.test.ts b/test/input.test.ts
new file mode 100644
index 0000000..80ddb40
--- /dev/null
+++ b/test/input.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { tiltSteer } from '../src/input.js'
+
+describe('steering by tilt', () => {
+  it('is nothing for a phone held level, or nearly', () => {
+    for (const roll of [0, 2, -3.9, 4]) expect(tiltSteer(40, roll, 0)).toBe(0)
+  })
+
+  it('steers the way the phone is leaned, more the further, and no more than fully', () => {
+    expect(tiltSteer(40, 10, 0)).toBeCloseTo(0.25, 5)
+    expect(tiltSteer(40, 16, 0)).toBeCloseTo(0.5, 5)
+    expect(tiltSteer(40, -16, 0)).toBeCloseTo(-0.5, 5)
+    expect(tiltSteer(40, 28, 0)).toBe(1)
+    expect(tiltSteer(40, 75, 0)).toBe(1)
+    expect(tiltSteer(40, -75, 0)).toBe(-1)
+  })
+
+  it('takes no notice of how far back the phone is tipped, held upright', () => {
+    for (const tip of [0, 30, 60, 85]) expect(tiltSteer(tip, 16, 0)).toBeCloseTo(0.5, 5)
+  })
+
+  it('held on its side, steers by what was its tip, whichever side is up', () => {
+    // Top edge to the left: leaning right is the top edge rising.
+    expect(tiltSteer(16, 0, 90)).toBeCloseTo(0.5, 5)
+    expect(tiltSteer(-16, 0, 90)).toBeCloseTo(-0.5, 5)
+    // Top edge to the right: the other way about.
+    expect(tiltSteer(16, 0, 270)).toBeCloseTo(-0.5, 5)
+    // And upside down, the roll is reversed.
+    expect(tiltSteer(0, 16, 180)).toBeCloseTo(-0.5, 5)
+  })
+})
diff --git a/test/settings.test.ts b/test/settings.test.ts
index 55b8041..9c3bf16 100644
--- a/test/settings.test.ts
+++ b/test/settings.test.ts
@@ -3,9 +3,9 @@ import { TIME_NAMES } from '../src/render/sky.js'
 import { anyLake, loadSettings, timeOf } from '../src/settings.js'
 
 describe('settings', () => {
-  it('starts with a surprise for the time of day and no impossible ice', () => {
+  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 })
+    expect(loadSettings()).toEqual({ time: 'surprise', impossibleIce: false, tilt: false })
   })
 
   it('picks a lake from a million', () => {