kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

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:
MCLAUDE.md | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mgames/fire-truck/game.js | 18+++++++++++++++---
Mjs/shared/game-shared.js | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mjs/shared/touch-controls.js | 5+++++
Mtests/browser/runner.js | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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();