kgames

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

CLAUDE.md (17239B)


      1 # KGames — Claude Guide
      2 
      3 ## What this project is
      4 
      5 Free educational games for young kids. No ads, no signup, no backend.
      6 Static HTML + CSS + JavaScript served from any web host. Phaser 3 and
      7 Tone.js loaded from CDN. GPL-3.0.
      8 
      9 No build tools — double-clicking `index.html` is a valid development
     10 workflow for the games that exist today, and worth preserving for them.
     11 New games are not bound by it; see "Constraints for new games".
     12 
     13 ## Testing — run before every commit
     14 
     15 ```bash
     16 npm install          # one-time; downloads playwright (~112 MB Chromium)
     17 npm test             # unit tests + browser smoke tests; must all pass
     18 npm run test:unit    # fast, no browser (~0.2 s)
     19 npm run test:browser # headless Chromium (~10 s)
     20 ```
     21 
     22 **Never commit if `npm test` fails.** The browser tests catch broken
     23 rendering that syntax checks miss (see the projection-bug incident).
     24 
     25 ## Architecture
     26 
     27 | Path | Purpose |
     28 |---|---|
     29 | `index.html` + `css/style.css` + `js/main.js` | Portal — game tile grid |
     30 | `games/<slug>/index.html` | HTML shell: CDN scripts, HUD buttons, game container |
     31 | `games/<slug>/lib.js` | Pure logic (projection math, route gen) — dual-export for unit tests |
     32 | `games/<slug>/game.js` | Phaser scene — all graphics via `generateTexture`, audio via Web Audio / Tone.js |
     33 | `games/story-stamp/` | DOM-based phonics game (no Phaser) — see "Story Stamp" |
     34 | `tools/story-stamp-assets.py` | Fetches + cuts Story Stamp's recorded audio and pictures, writes its manifest and credits |
     35 | `tools/story-stamp-audit.py` | Checks the phoneme clips with independent methods (acoustics, cross-speaker match, Vosk recognition, spectrograms) |
     36 | `js/shared/game-shared.js` | Shared `KGames.*` helpers (AudioSystem, win screen, confetti, progress bar) |
     37 | `js/shared/touch-controls.js` | On-screen keyboard / D-pad for phones — see "Mobile / touch controls" |
     38 | `assets/thumbnails/<slug>.svg` | 160×130 viewBox, rx=18, stroke from palette, `#FFF5F5` fill |
     39 | `tests/unit/` | `node --test` unit tests — no browser, no Phaser |
     40 | `tests/browser/runner.js` | Playwright smoke tests — no JS errors + canvas renders |
     41 | `tests/serve.js` | Tiny Node HTTP server used by browser tests (and for local dev) |
     42 
     43 ## Top-down rendering (fire-truck)
     44 
     45 Fire-truck is a **top-down** driving game: the camera zooms 1/`SCALE` (SCALE=5)
     46 and follows the truck over a 50×50-cell island. `lib.js` `buildIsland()` lays
     47 out water/beach ring roads + BSP-subdivided interior blocks; the road graph
     48 drives the route and fire pathing.
     49 
     50 **Chunk bake pipeline** (`renderIsland()` in game.js). The static island is
     51 baked once into 16×16-cell chunk textures at `BAKE_CELL = 96` px/cell, drawn
     52 ×SCALE (zoom 1/SCALE) → **~1:1 on screen**, so ~96 px of detail per cell costs
     53 nothing per frame and Phaser culls offscreen chunks for free. Each chunk runs
     54 named passes into one `Graphics`, in order: `_bakeGround` → `_bakeSidewalks`
     55 (incl. road corner masks) → `_bakeRoadMarkings` → `_bakeBuildings` → `_bakeParks`
     56 → `_bakePalms`.
     57 
     58 - **1-cell apron rule** (highest visual-bug risk): every pass iterates a 1-cell
     59   margin around the chunk (clamped) at true local coords so overhanging art —
     60   SE drop shadows, palm fronds, block-spanning buildings — that crosses a chunk
     61   seam is re-drawn by the neighbour; `generateTexture` clips the overflow.
     62 - Buildings are drawn **per block, not per cell**: `FT.findBuildingBlocks(grid)`
     63   covers all building cells with disjoint rectangles (BSP guarantees rectangular
     64   road-bounded blocks) so each block reads as one building with one roof.
     65   `FT.assignParks()` tags ~15% of blocks `cell.park = true` (**type stays
     66   `building`**, so grid/route tests are untouched; parks are excluded as fire
     67   destinations).
     68 
     69 **Ocean underlay**: a screen-fixed (`scrollFactor 0`) `TileSprite` at depth −1
     70 drifts each frame. A world-sized TileSprite would allocate a multi-GB fill
     71 canvas — keep it viewport-sized.
     72 
     73 **Dual cameras** (game.js): `cameras.main` is the world; `uiCamera` is the HUD.
     74 Every new **world** object must be hidden from the HUD camera —
     75 `this._addWorld(obj, depth)` sets depth + `uiCamera.ignore`. HUD objects are
     76 added to `cameras.main.ignore(...)`.
     77 
     78 **Depth ordering** (exact values live in game.js): ocean < chunks <
     79 boats/buoys < peds < bikes < cars/buses < burning overlay < fire glow < fire <
     80 spray < smoke < cloud shadows < truck < birds < HUD.
     81 
     82 **Hose minigame** (`FireHoseScene`): when the truck reaches the fire, the
     83 driving scene enters state `minigame`, pauses itself, and launches a
     84 street-view scene — building facade with a window grid (`FT.buildFacade` in
     85 lib.js, pure + unit-tested), arrow keys move an aim reticle, SPACE sprays from
     86 the truck's rotating ladder. Quenched windows reveal waving residents/a cat;
     87 when all are out the scene resumes the driving scene and calls
     88 `_onMinigameComplete()`. **Headless-RAF rule**: gameplay logic lives in
     89 `_step()` driven from both `update()` and a wall-clock `setInterval` fallback
     90 (same trick as the driving scene's `fallbackTimer`), and all scene transitions
     91 use wall-clock `setTimeout` — never `this.time.delayedCall`, which starves in
     92 throttled/headless browsers and hangs the tests.
     93 
     94 **Shared cartoony art** (file-scope helpers in game.js): `bakePeopleTextures`
     95 (`kg-person-0..7`, racially diverse cartoon townspeople), `bakeSideTruckTexture`
     96 (`kg-truck-side`), `bakeLadderTexture`, `bakeCritterTextures` (dog/cat/parrot/
     97 iguana), `drawSidePalm`. Used by both `FireHoseScene` and `FireTruckEndScene` —
     98 reuse these rather than drawing new people/trucks.
     99 
    100 **Ambient life** (`_initAmbient` / `_updateAmbient`, driven from `update()` —
    101 never `step()`, which tests call directly): baked-texture Images for traffic
    102 (`FT.advanceCarPlan` graph walk), pedestrians, cloud shadows, boats, birds.
    103 Guard with `this.ambientReady`; clean up in `shutdown()`. All decor variety is
    104 deterministic via `FT.hashCell(x, y, salt)` so the island bakes identically
    105 every run.
    106 
    107 ## Story Stamp (DOM game, recorded audio)
    108 
    109 Story Stamp teaches blending (synthetic phonics) and is the first game built
    110 outside Phaser: plain DOM + CSS animations in `games/story-stamp/`, keyboard
    111 only. It is the reference for "Constraints for new games" in practice.
    112 
    113 The child runs the checkout desk of the island library. Patrons queue with a
    114 topic sign (bugs, space, dinosaurs …); the child picks one, gets a request
    115 slip with the title in sound boxes, stamps each letter (hearing its sound),
    116 presses SPACE to sound it out, and picks the matching book from the cart.
    117 **The game never says the word the child is decoding** — three blend passes
    118 with shrinking gaps, then the picture choice is the proof. "Returns" (patron
    119 says the word, child types its sounds) unlock after five mastered words.
    120 
    121 - `lib.js` — pure content + logic: `PHONEMES` (with `stretch` for held
    122   sounds and `home` for override sounds), `GRAPHEMES`/`BASE`/`SEQUENCE` (the
    123   teaching order, s-a-t-p-i-n first, digraphs and vowel teams later), `TOPICS`,
    124   `WORDS` (decodable words; `g` is the grapheme split, `art` the Fluent Emoji
    125   asset, `t` topics, `p` overrides ambiguous sounds like book's `oo` or
    126   robot's first `o` — override words are only offered once the child knows
    127   the grapheme that teaches that sound), `PATRONS`, the mastery /
    128   spaced-repetition scheduler (`recordOrder`, `pickNext`, `shouldIntroduce`),
    129   `buildQueue` (distinct topics per patron), `blendTimeline` (no word event),
    130   and the multi-child profile store. Unit-tested in
    131   `tests/unit/story-stamp-*.test.js`.
    132 - `audio.js` — Web Audio player for the recorded clips, speech synthesis for
    133   everything else, oscillator SFX. **Phonemes are never synthesised** and
    134   **never looped**: held sounds are ~1 s recordings and `playPhoneme(id,
    135   {len})` cuts them with a fade. A missing clip is silent. `audio.log`
    136   records every clip for the tests.
    137 - `game.js` — screens + desk flow. Exposes `window.__SS_GAME__` (`state`,
    138   `order`, `queue`, `profile`, `audio`) and `quickStart(name)` (lands on the
    139   queue) for tests; honours `window.__SS_NOW__` (clock) and
    140   `window.__SS_FORCE_MODE__` (`'return'`).
    141 - **Assets are generated, not hand-edited.** `python3 tools/story-stamp-assets.py`
    142   downloads Wikimedia Commons recordings and Fluent Emoji 3D PNGs and writes
    143   `assets/manifest.js` + `CREDITS.md`. Continuants and short vowels are cut
    144   from IPA recordings and held to ~1 s by WSOLA time-stretching of the steady
    145   middle; stops/affricates are cut from the start of word recordings (burst +
    146   aspiration, up to voicing); r-coloured vowels and diphthongs are whole word
    147   recordings ("are", "or", "ow", "boy" minus the b …). Add a word to `WORDS`
    148   with a Fluent asset name, rerun the script (`--offline` reuses `.cache/`),
    149   commit the outputs. The assets unit test checks manifest ↔ lib ↔ credits.
    150 - **Audit the clips after touching the pipeline:** `python3
    151   tools/story-stamp-audit.py` (Vosk in `.cache/pylib`, model in
    152   `.cache/vosk`; `--no-asr` without them). It checks acoustics per clip
    153   (duration class, voicing, centroid, stutter = looping, level flatness),
    154   matches each clip against sounds cut from a *different speaker's* word
    155   recordings, runs Vosk over words assembled from the clips (about half
    156   recognised at speech rate is normal for concatenated sounds; look for one
    157   sound far below the rest), and writes a spectrogram sheet to
    158   `.cache/audit/phonemes.png` to look at.
    159 - Age rules the code enforces: no timers/lives/fail state (a wrong key
    160   re-demonstrates the sound; after three wrong books the word is given and the
    161   book pointed out), every instruction is spoken, six books make a "day",
    162   meaning is checked with the book choice, progress is per-child in
    163   `localStorage` (`kg-story-stamp-v1`).
    164 
    165 ## Mobile / touch controls
    166 
    167 Phones have no physical keyboard and a Phaser canvas can't summon the native
    168 one, so `js/shared/touch-controls.js` renders an on-screen control bar whose
    169 buttons dispatch **synthetic `KeyboardEvent`s to `window`**. Phaser's keyboard
    170 plugin already listens on `window`, so every game's existing key handling works
    171 unchanged — game logic never references this module.
    172 
    173 - Opt in from the HTML shell, after `game.js`:
    174   `<script>KGames.initTouchControls({ layout: 'alphabet' });</script>`
    175 - Layouts: `alphabet` (A–Z), `text` (A–Z + Space/Backspace/Enter, e.g. work),
    176   `text-nav` (text + arrow row, e.g. story-stamp), `drive` (arrows +
    177   Music/Siren/Lights/Spray, e.g. fire-truck).
    178 - Shown only on touch / coarse-pointer devices. Force with `?kbd=1` / `?kbd=0`
    179   or `window.__FORCE_TOUCH_CONTROLS__` (tests use the latter).
    180 - The bar is a flex child below `#game-container`, so the canvas shrinks to fit
    181   above it. Phaser's `Scale.RESIZE` ignores a synthetic window `resize` when the
    182   window size is unchanged, so the module calls `game.scale.refresh()` directly.
    183   **This requires the game instance on `window.__KG_GAME__`** — assign it where
    184   you call `new Phaser.Game(...)`.
    185 
    186 ## Adding a new game
    187 
    188 The checklist below is the **Phaser + no-build** recipe the current games
    189 follow. It is a well-worn default, not a requirement — read "Constraints for
    190 new games" first and deviate where the game calls for it.
    191 
    192 1. Create `games/<slug>/index.html` (clone from `games/fire-truck/index.html`)
    193 2. Create `games/<slug>/lib.js` for pure (unit-testable) logic
    194 3. Create `games/<slug>/game.js` for the Phaser scene
    195 4. Add `render: { preserveDrawingBuffer: true }` to the `Phaser.Game` config
    196    (required for the Playwright pixel-reading tests to work), and assign the game
    197    to `window.__KG_GAME__` so touch controls can resize it
    198 5. Create `assets/thumbnails/<slug>.svg` (160×130, rx=18 rounded rect)
    199 6. Replace a `Coming Soon` placeholder in `js/main.js`
    200 7. Wire up touch controls: `KGames.initTouchControls({ layout: ... })` in the
    201    HTML shell (pick or add the layout that matches the game's keys)
    202 8. Add unit tests in `tests/unit/<slug>-*.test.js`
    203 9. Add browser test cases to `tests/browser/runner.js`
    204 10. Run `npm test` — all must pass before committing
    205 
    206 ## Hosting & deploy
    207 
    208 The project is self-hosted on NearlyFreeSpeech — there is no Codeberg/GitHub forge.
    209 
    210 - `origin` is `ssh://…@ssh.nyc1.nearlyfreespeech.net/home/private/kgames.git`
    211   (a bare repo outside the document root).
    212 - A **`pre-push` hook** runs `deploy.sh` on every push to `origin`, which rsyncs
    213   the site, refreshes the restic-backed mirror at
    214   `/media/bespin/kyle/backups/gits/kgames.git`, and publishes a static
    215   [stagit](https://codemadness.org/stagit.html) code browser + dumb-HTTP clone
    216   source to `https://www.keyboard.games/code/`. Skip with `KG_SKIP_DEPLOY=1`.
    217 - `deploy.sh` and the hook are **untracked**, so a fresh clone does not reproduce
    218   this setup — copies plus restore notes live in
    219   `/media/bespin/kyle/backups/gits/kgames-untracked/`.
    220 - The mirror is deliberately **never repacked** so restic dedups its objects.
    221 - `tools/stagit-style.css` is the code browser's stylesheet (site palette).
    222 
    223 ## Asset policy
    224 
    225 **Prefer real, openly licensed art made by human artists.** Procedural
    226 generation and AI generation are not the house style and must not be the
    227 default for a game's character and world art.
    228 
    229 Priority order for any new art or audio:
    230 
    231 1. Existing open-license work (CC0, CC-BY, CC-BY-SA, OFL, or otherwise
    232    GPL-3.0-compatible) — OpenGameArt, Kenney, open itch.io packs, Wikimedia
    233    Commons, Freesound, and similar.
    234 2. Commissioned or hand-made original work contributed under a
    235    GPL-3.0-compatible license.
    236 3. Procedural generation — fine for incidental effects (particles, gradients,
    237    simple shapes, UI chrome), not for characters, creatures, or environments.
    238 4. AI-generated assets — avoid.
    239 
    240 The same order applies to audio: real recorded voice and openly licensed music
    241 and SFX beat synthesised equivalents, so use human recordings when they can be
    242 found (Wikimedia Commons has thousands of CC-licensed word pronunciations and
    243 IPA sound recordings — see `tools/story-stamp-assets.py`). **Synthesised speech
    244 is acceptable** where no recording exists or the text is dynamic
    245 (instructions, character chatter, a word with no clip): use the browser's Web
    246 Speech API, which needs no server. Where a game depends on audio accuracy
    247 (phoneme audio for phonics), recorded assets are still expected.
    248 
    249 Record every third-party asset's source, author, and license in the repo, and
    250 surface attribution wherever the license requires it. **GPL-3.0 compatibility
    251 is a hard requirement**, never a preference.
    252 
    253 ## Constraints for new games
    254 
    255 The rules above under "Architecture" and "Key conventions" describe how the
    256 *existing* games are built. They are not a cage for new ones. A new game may
    257 use a different engine, a build step, bundled dependencies, and real image and
    258 audio asset files — pick whatever serves the game.
    259 
    260 What still holds for everything in this repo:
    261 
    262 - **Open source, GPL-3.0**, with license-compatible dependencies and assets.
    263 - **No ads, no signup, no backend, no tracking.** Static hosting only; any
    264   saved progress stays on the player's device.
    265 - **Tested** — new work ships with unit and browser coverage, and
    266   `npm test` must pass before every commit.
    267 - **Keyboard-first** — a game must be fully playable from the keyboard alone.
    268   Pointer input may be a convenience; no mechanic may depend on it. Phones are
    269   served by the on-screen keyboard in `js/shared/touch-controls.js`.
    270 
    271 ## Color palette
    272 
    273 `#E63946` red · `#1D7CF2` blue · `#FFD23F` yellow · `#2EC4B6` teal
    274 
    275 Tint equivalents: `0xE63946 · 0x1D7CF2 · 0xFFD23F · 0x2EC4B6`
    276 
    277 ## Key conventions
    278 
    279 - **Existing games use no external image assets** — all textures via
    280   `make.graphics({ add: false }).generateTexture()`. This is a description of how
    281   the current games were built, **not a rule for new ones** — see "Asset policy".
    282 - **Audio**: Tone.js for looping music; Web Audio API oscillators for SFX
    283   (lazy-init on first user interaction to satisfy browser autoplay policy).
    284   **Never start music/SFX straight from `Tone.start()` or a first keydown** —
    285   on phones the first key comes from the touch bar's `pointerdown`, which does
    286   not grant user activation, so contexts stay suspended (Safari never
    287   auto-resumes). Hand every AudioContext to `KGames.installAudioUnlock(...)`
    288   and do the "audio is up" work in its callback; the `audio:` browser tests
    289   emulate the Safari policy and will catch regressions.
    290 - **Music is opt-in.** Background music and the fire-truck siren start OFF and
    291   are switched on by the player (`M` / `S`, mirrored in the settings panel and
    292   the touch bar), so the first audio work a browser ever sees happens inside a
    293   real keypress — the one moment every browser (Edge on macOS included) accepts.
    294   Fire-truck also runs a once-a-second watchdog (`_audioWatchdog`) that re-nudges
    295   a Tone transport that failed to start, and the on-canvas HUD line says whether
    296   music is off, starting, or on.
    297 - **Font**: Fredoka, 400 + 600 weight (Google Fonts)
    298 - **Scale**: `Phaser.Scale.RESIZE` + `applyLayout(W, H)` / `onResize` pattern
    299 - **No `console.log` in production code** — browser tests assert zero console errors
    300 - **`window.__FT_SCENE__ = this`** exposed in `create()` for browser test inspection