commit 36a6fbb162b8bbaeb528a293479fc864f12a93e5
parent 5abdcdf62939b4ac47f5ce56a0af202b9a3f1671
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Mon, 14 Sep 2026 15:26:31 -0700
Fire truck audio fix and agent instruction change
Diffstat:
5 files changed, 289 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -7,7 +7,8 @@ Static HTML + CSS + JavaScript served from any web host. Phaser 3 and
Tone.js loaded from CDN. GPL-3.0.
No build tools — double-clicking `index.html` is a valid development
-workflow. Keep it that way.
+workflow for the games that exist today, and worth preserving for them.
+New games are not bound by it; see "Constraints for new games".
## Testing — run before every commit
@@ -122,6 +123,10 @@ unchanged — game logic never references this module.
## Adding a new game
+The checklist below is the **Phaser + no-build** recipe the current games
+follow. It is a well-worn default, not a requirement — read "Constraints for
+new games" first and deviate where the game calls for it.
+
1. Create `games/<slug>/index.html` (clone from `games/fire-truck/index.html`)
2. Create `games/<slug>/lib.js` for pure (unit-testable) logic
3. Create `games/<slug>/game.js` for the Phaser scene
@@ -153,6 +158,49 @@ The project is self-hosted on NearlyFreeSpeech — there is no Codeberg/GitHub f
- The mirror is deliberately **never repacked** so restic dedups its objects.
- `tools/stagit-style.css` is the code browser's stylesheet (site palette).
+## Asset policy
+
+**Prefer real, openly licensed art made by human artists.** Procedural
+generation and AI generation are not the house style and must not be the
+default for a game's character and world art.
+
+Priority order for any new art or audio:
+
+1. Existing open-license work (CC0, CC-BY, CC-BY-SA, OFL, or otherwise
+ GPL-3.0-compatible) — OpenGameArt, Kenney, open itch.io packs, Wikimedia
+ Commons, Freesound, and similar.
+2. Commissioned or hand-made original work contributed under a
+ GPL-3.0-compatible license.
+3. Procedural generation — fine for incidental effects (particles, gradients,
+ simple shapes, UI chrome), not for characters, creatures, or environments.
+4. AI-generated assets — avoid.
+
+The same order applies to audio: real recorded voice and openly licensed music
+and SFX beat synthesised equivalents. Where a game depends on audio quality
+(e.g. accurate phoneme audio for phonics), recorded assets are expected.
+
+Record every third-party asset's source, author, and license in the repo, and
+surface attribution wherever the license requires it. **GPL-3.0 compatibility
+is a hard requirement**, never a preference.
+
+## Constraints for new games
+
+The rules above under "Architecture" and "Key conventions" describe how the
+*existing* games are built. They are not a cage for new ones. A new game may
+use a different engine, a build step, bundled dependencies, and real image and
+audio asset files — pick whatever serves the game.
+
+What still holds for everything in this repo:
+
+- **Open source, GPL-3.0**, with license-compatible dependencies and assets.
+- **No ads, no signup, no backend, no tracking.** Static hosting only; any
+ saved progress stays on the player's device.
+- **Tested** — new work ships with unit and browser coverage, and
+ `npm test` must pass before every commit.
+- **Keyboard-first** — a game must be fully playable from the keyboard alone.
+ Pointer input may be a convenience; no mechanic may depend on it. Phones are
+ served by the on-screen keyboard in `js/shared/touch-controls.js`.
+
## Color palette
`#E63946` red · `#1D7CF2` blue · `#FFD23F` yellow · `#2EC4B6` teal
@@ -161,9 +209,17 @@ Tint equivalents: `0xE63946 · 0x1D7CF2 · 0xFFD23F · 0x2EC4B6`
## Key conventions
-- **No external image assets** — all textures via `make.graphics({ add: false }).generateTexture()`
+- **Existing games use no external image assets** — all textures via
+ `make.graphics({ add: false }).generateTexture()`. This is a description of how
+ the current games were built, **not a rule for new ones** — see "Asset policy".
- **Audio**: Tone.js for looping music; Web Audio API oscillators for SFX
- (lazy-init on first user interaction to satisfy browser autoplay policy)
+ (lazy-init on first user interaction to satisfy browser autoplay policy).
+ **Never start music/SFX straight from `Tone.start()` or a first keydown** —
+ on phones the first key comes from the touch bar's `pointerdown`, which does
+ not grant user activation, so contexts stay suspended (Safari never
+ auto-resumes). Hand every AudioContext to `KGames.installAudioUnlock(...)`
+ and do the "audio is up" work in its callback; the `audio:` browser tests
+ emulate the Safari policy and will catch regressions.
- **Font**: Fredoka, 400 + 600 weight (Google Fonts)
- **Scale**: `Phaser.Scale.RESIZE` + `applyLayout(W, H)` / `onResize` pattern
- **No `console.log` in production code** — browser tests assert zero console errors
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -476,6 +476,8 @@ class FireTruckScene extends Phaser.Scene {
this.sirenOn = false;
this.lightsOn = false;
this._audioInited = false;
+ this._audioReady = false;
+ this._audioUnlock = null;
this.bgGain = null;
this.melodySynth = null;
this.bassSynth = null;
@@ -1404,16 +1406,23 @@ class FireTruckScene extends Phaser.Scene {
if (held) this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
}
+ // Creates the audio contexts and arms the shared unlock helper. On phones the
+ // first key press arrives from the touch bar's pointerdown, which does not
+ // grant user activation, so the contexts start suspended; the helper resumes
+ // them from the pointerup/touchend that follows and only then starts the
+ // music and any siren the player has already switched on.
initAudio() {
if (this._audioInited) return;
this._audioInited = true;
try {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (_) {}
- Tone.start().then(() => {
+ const toneCtx = KGames.toneContext();
+ this._audioUnlock = KGames.installAudioUnlock(() => [this.audioCtx, toneCtx], () => {
+ this._audioReady = true;
this.initMusic();
if (this.sirenOn) this._startSiren();
- }).catch(() => {});
+ });
}
initMusic() {
@@ -1461,7 +1470,9 @@ class FireTruckScene extends Phaser.Scene {
}
_startSiren() {
- if (this.sirenOsc || !this.audioCtx) return;
+ // Before the contexts are running there's nothing to hear; sirenOn is kept
+ // and the unlock callback in initAudio() starts the siren once audio is up.
+ if (this.sirenOsc || !this.audioCtx || !this._audioReady) return;
try {
const ctx = this.audioCtx;
const osc = ctx.createOscillator();
@@ -2211,6 +2222,7 @@ class FireTruckScene extends Phaser.Scene {
}
this._stopSiren();
this._stopSmoke();
+ if (this._audioUnlock) { this._audioUnlock.dispose(); this._audioUnlock = null; }
if (this.bgGain) { try { Tone.Transport.stop(); } catch (_) {} }
if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; }
diff --git a/js/shared/game-shared.js b/js/shared/game-shared.js
@@ -176,6 +176,79 @@
return elements;
}
+ // ── Audio unlock ──────────────────────────────────────────────────────────
+ // Browsers create AudioContexts "suspended" until the page has user
+ // activation, and only some input events grant it: keydown, mousedown,
+ // pointerup, touchend, click. A *touch* pointerdown does not — and that is
+ // the event the on-screen control bar dispatches its synthetic keys from.
+ // So on a phone the "start audio on first key press" path runs before
+ // activation exists: the contexts stay suspended and their resume()
+ // promises never settle. Chrome quietly auto-resumes on the next real
+ // gesture; Safari never does, so music and SFX stay silent for the whole
+ // session. iOS also flips a running context to "interrupted" on a phone
+ // call or app switch.
+ //
+ // installAudioUnlock(getContexts, onReady) calls resume() on every context
+ // returned by getContexts() from each activating gesture (and on return to
+ // the foreground) until all report "running", then calls onReady once.
+ // The listeners stay installed so an interrupted context is revived by the
+ // next tap. Returns { attempt, dispose, ready }.
+ const UNLOCK_EVENTS = ['pointerup', 'touchend', 'mouseup', 'click', 'keydown'];
+
+ function installAudioUnlock(getContexts, onReady) {
+ let ready = false;
+ let disposed = false;
+
+ function contexts() {
+ try { return (getContexts() || []).filter(Boolean); } catch (_) { return []; }
+ }
+
+ function check() {
+ if (ready || disposed) return;
+ const ctxs = contexts();
+ if (!ctxs.length || !ctxs.every((c) => c.state === 'running')) return;
+ ready = true;
+ if (onReady) onReady();
+ }
+
+ function attempt() {
+ if (disposed) return;
+ contexts().forEach((c) => {
+ if (c.state === 'running') return;
+ try {
+ const p = c.resume();
+ if (p && typeof p.then === 'function') p.then(check, () => {});
+ } catch (_) {}
+ });
+ check();
+ }
+
+ const onVisible = () => { if (document.visibilityState !== 'hidden') attempt(); };
+ UNLOCK_EVENTS.forEach((t) => root.addEventListener(t, attempt, { capture: true, passive: true }));
+ document.addEventListener('visibilitychange', onVisible);
+ attempt();
+
+ return {
+ attempt,
+ get ready() { return ready; },
+ dispose() {
+ disposed = true;
+ UNLOCK_EVENTS.forEach((t) => root.removeEventListener(t, attempt, { capture: true }));
+ document.removeEventListener('visibilitychange', onVisible);
+ },
+ };
+ }
+
+ // Tone.js's own context. Tone.start() / Tone.getContext().resume() only act
+ // when state === 'suspended', which skips iOS's 'interrupted' state, so hand
+ // the unlock helper the underlying context instead.
+ function toneContext() {
+ try {
+ const c = Tone.getContext();
+ return c.rawContext || c;
+ } catch (_) { return null; }
+ }
+
// ── Audio system ──────────────────────────────────────────────────────────
// Encapsulates Tone.js background music and Web Audio SFX.
// Pass melody/harmony/bass note arrays and bpm to constructor;
@@ -252,7 +325,12 @@
Tone.Transport.bpm.value = this._bpm;
this.bgGain.gain.value = this.musicMuted ? 0 : 1;
- Tone.start().then(() => Tone.Transport.start());
+ // Create the SFX context now so the same gesture unlocks it alongside
+ // Tone's, then start the transport once both are actually running.
+ let sfxCtx = null;
+ try { sfxCtx = this._ctx(); } catch (_) {}
+ const toneCtx = toneContext();
+ this._unlock = installAudioUnlock(() => [toneCtx, sfxCtx], () => Tone.Transport.start());
}
playSuccessJingle() {
@@ -416,6 +494,8 @@
}
root.KGames.AudioSystem = AudioSystem;
+ root.KGames.installAudioUnlock = installAudioUnlock;
+ root.KGames.toneContext = toneContext;
root.KGames.bakeFireworksAtlas = bakeFireworksAtlas;
root.KGames.burstConfetti = burstConfetti;
root.KGames.launchFireworks = launchFireworks;
diff --git a/js/shared/touch-controls.js b/js/shared/touch-controls.js
@@ -15,6 +15,11 @@
//
// Shown only on touch / coarse-pointer devices. Force on/off with the URL
// param ?kbd=1 / ?kbd=0 or window.__FORCE_TOUCH_CONTROLS__ (used by tests).
+//
+// Keys fire on pointerdown for snappy response. A touch pointerdown does NOT
+// grant user activation, so a game must not rely on its "first key press" to
+// start audio — use KGames.installAudioUnlock (game-shared.js), which resumes
+// the AudioContexts from the pointerup/touchend that follows.
(function (root) {
'use strict';
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -11,8 +11,11 @@ async function main() {
let pass = 0;
let fail = 0;
- async function test(name, fn) {
- const page = await browser.newPage();
+ async function test(name, fn, opts) {
+ // opts.hasTouch: open the page in a touch-capable context so ElementHandle.tap()
+ // sends real touch events (pointerdown -> pointerup/touchend), like a phone.
+ const context = opts && opts.hasTouch ? await browser.newContext({ hasTouch: true }) : null;
+ const page = context ? await context.newPage() : await browser.newPage();
// Speed up fire-truck tests
await page.addInitScript(() => {
window.GAME_SPEED_MULTIPLIER = 8.0;
@@ -31,6 +34,7 @@ async function main() {
fail++;
} finally {
await page.close();
+ if (context) await context.close();
}
}
@@ -871,6 +875,129 @@ async function main() {
});
}
+
+ // ── Audio unlock on phones ──────────────────────────────────────────────────
+ //
+ // Headless Chromium runs with autoplay allowed and, even under the real
+ // policy, quietly auto-resumes a blocked AudioContext on the next gesture.
+ // Safari does neither: a context created or resumed outside an activating
+ // event (keydown / pointerup / touchend / click — NOT a touch pointerdown)
+ // stays suspended forever. Emulate that at the Web Audio API level so the
+ // test fails the way an iPhone does.
+ function emulateSafariAutoplayPolicy() {
+ const ACTIVATING = ['pointerup', 'touchend', 'click', 'keydown', 'mousedown', 'mouseup'];
+ let inGesture = false;
+ ACTIVATING.forEach((t) => window.addEventListener(t, (e) => {
+ if (!e.isTrusted) return;
+ inGesture = true;
+ setTimeout(() => { inGesture = false; }, 0);
+ }, true));
+ const unlocked = new WeakSet();
+ const stateDesc = Object.getOwnPropertyDescriptor(BaseAudioContext.prototype, 'state');
+ Object.defineProperty(BaseAudioContext.prototype, 'state', {
+ configurable: true,
+ get() {
+ const real = stateDesc.get.call(this);
+ return real === 'running' && !unlocked.has(this) ? 'suspended' : real;
+ },
+ });
+ const origResume = AudioContext.prototype.resume;
+ AudioContext.prototype.resume = function () {
+ if (!inGesture) return new Promise(() => {}); // pending forever, like WebKit
+ unlocked.add(this);
+ return origResume.call(this);
+ };
+ window.__SAFARI_AUDIO_EMULATED__ = true;
+ }
+
+ async function fireTruckAudioState(page) {
+ return page.evaluate(() => {
+ const s = window.__FT_SCENE__;
+ return {
+ inited: s._audioInited,
+ ready: s._audioReady,
+ ctxState: s.audioCtx ? s.audioCtx.state : 'none',
+ toneState: Tone.getContext().state,
+ music: !!s.bgGain,
+ transport: Tone.Transport.state,
+ sirenOn: s.sirenOn,
+ sirenOsc: !!s.sirenOsc,
+ };
+ });
+ }
+
+ await test('audio: emulated Safari policy really blocks contexts created on touch pointerdown', async (page, url) => {
+ await page.addInitScript(emulateSafariAutoplayPolicy);
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.waitForFunction(() => !!window.__FT_SCENE__);
+ // Synthetic key events (what the touch bar dispatches) are not trusted, so
+ // audio started from them must stay suspended under the emulation.
+ await page.evaluate(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', bubbles: true }));
+ window.dispatchEvent(new KeyboardEvent('keyup', { key: 's', code: 'KeyS', bubbles: true }));
+ });
+ await page.waitForTimeout(300);
+ const st = await fireTruckAudioState(page);
+ if (!st.inited) throw new Error('initAudio did not run on synthetic keydown');
+ if (st.ready || st.ctxState === 'running' || st.toneState === 'running') {
+ throw new Error(`Emulation is not blocking audio: ${JSON.stringify(st)}`);
+ }
+ if (!st.sirenOn) throw new Error('siren toggle did not register');
+ }, { hasTouch: true });
+
+ await test('audio: fire-truck music + siren start from a touch-bar tap under Safari policy', async (page, url) => {
+ await page.addInitScript(emulateSafariAutoplayPolicy);
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.waitForFunction(() => !!window.__FT_SCENE__);
+ await page.waitForTimeout(300);
+ const siren = await touchKey(page, '🔊 Siren');
+ await siren.tap(); // touchstart/pointerdown (key fires here) -> pointerup/touchend (activation)
+ await page.waitForFunction(() => window.__FT_SCENE__._audioReady === true, null, { timeout: 4000 })
+ .catch(async () => { throw new Error(`audio never became ready: ${JSON.stringify(await fireTruckAudioState(page))}`); });
+ const st = await fireTruckAudioState(page);
+ if (st.ctxState !== 'running') throw new Error(`SFX context not running: ${JSON.stringify(st)}`);
+ if (st.toneState !== 'running') throw new Error(`Tone context not running: ${JSON.stringify(st)}`);
+ if (!st.music) throw new Error(`music not initialised: ${JSON.stringify(st)}`);
+ if (st.transport !== 'started') throw new Error(`Tone.Transport not started: ${JSON.stringify(st)}`);
+ if (!st.sirenOn || !st.sirenOsc) throw new Error(`siren not sounding after tap: ${JSON.stringify(st)}`);
+ // A second tap turns the siren off again.
+ await siren.tap();
+ await page.waitForTimeout(100);
+ const off = await fireTruckAudioState(page);
+ if (off.sirenOn || off.sirenOsc) throw new Error(`siren did not stop on second tap: ${JSON.stringify(off)}`);
+ }, { hasTouch: true });
+
+ await test('audio: fire-truck music starts from a physical key press under Safari policy', async (page, url) => {
+ await page.addInitScript(emulateSafariAutoplayPolicy);
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => !!window.__FT_SCENE__);
+ await page.waitForTimeout(300);
+ await page.keyboard.press('ArrowUp');
+ await page.waitForFunction(() => window.__FT_SCENE__._audioReady === true, null, { timeout: 4000 })
+ .catch(async () => { throw new Error(`audio never became ready: ${JSON.stringify(await fireTruckAudioState(page))}`); });
+ const st = await fireTruckAudioState(page);
+ if (!st.music || st.transport !== 'started') throw new Error(`music not running: ${JSON.stringify(st)}`);
+ });
+
+ await test('audio: letter-find music starts from a touch-bar tap under Safari policy', async (page, url) => {
+ await page.addInitScript(emulateSafariAutoplayPolicy);
+ await page.addInitScript(() => { window.__FORCE_TOUCH_CONTROLS__ = true; });
+ await page.goto(`${url}/games/letter-find/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#kg-touch');
+ await page.waitForFunction(() => !!window.__LF_SCENE__);
+ await page.waitForTimeout(500);
+ const key = await touchKey(page, 'A');
+ await key.tap();
+ await page.waitForFunction(() => Tone.Transport.state === 'started', null, { timeout: 4000 })
+ .catch(() => { throw new Error('Tone.Transport never started from a touch tap'); });
+ const toneState = await page.evaluate(() => Tone.getContext().state);
+ if (toneState !== 'running') throw new Error(`Tone context is ${toneState}`);
+ }, { hasTouch: true });
+
// ── Summary ───────────────────────────────────────────────────────────────
await browser.close();