Shared components for the family games (WordVoyage,
Chicken Cross, and whatever comes next).
Plain scripts, no build step — everything hangs off a single GK global.
▶ Play the demo — a tiny reflex game that exercises every component; read its source as living documentation.
| File | What it does |
|---|---|
gk/gk-util.js |
helpers: esc, clamp, lerp, rand, pick, shade, hash2, plus seededRand/seedFrom — a reproducible RNG for daily challenges and deterministic tests |
gk/gk-audio.js |
GK.Sfx — WebAudio synth (tone, noise) + default click/coin/win/lose/wrong; games add their own jingles. Wakes its own AudioContext on the first gesture, so menus are audible without the game calling init() |
gk/gk-ui.js |
GK.UI — screens (.screen + #screen-NAME), modals (.modal.visible), toast, sound toggle, and bindMenuClicks() — one delegated listener that clicks any menu button whose own handler stayed silent |
gk/gk-storage.js |
GK.createStorage(cfg) — localStorage persistence + optional Firestore family sync (profiles, progress, tombstoned deletes; progress writes debounced, flushed on tab hide/close) |
gk/gk-profiles.js |
GK.Profiles — emoji-avatar roster, 4-digit PINs with admin override, type-name delete, leaderboard renderer; injects its own modals |
gk/gk-pwa.js |
GK.initPWA() — service-worker registration + Add-to-Home-Screen button (beforeinstallprompt on Chrome, instructions modal on iOS). Also GK.Version: shows "Version 12 · 14 Sep 2026" at the foot of the start-up screen, then "✓ Latest" or an Update button by comparing the page's <meta name="gk-version"> with the deployed version.json |
gk/gk-fx.js |
GK.Fx — canvas juice: pooled particles (burst/trail/dust/sparkle/splash/confetti), screen shake, flash, floating text, lightning, slow-mo; plus GK.Tween. Per-game feel via GK.Fx.configure({...}) |
gk/gk-debug.js |
GK.Debug — dev tools behind ?debug=1: floating panel, FPS, toggle/action/jump controls games register themselves. Suppresses progress writes while on |
gk/gk-path.js |
GK.Route + GK.Corridor — levels authored as waypoints instead of tile maps, plus the linters that keep them honest. A route is a polyline you walk by distance (Turret Town's roads); a corridor is a centre-line-and-width profile you sample across (Rocket Rescue's caves), with place(x, t) for positioning content by a fraction of the passage so it can never land inside solid geometry |
gk/gk-base.css |
shared styles for all of the above, themed via --gk-* custom properties (button lettering is --gk-btn-ink, defaulting to --gk-ink; set it to #fff if your game's accent is dark); also makes the hidden attribute authoritative ([hidden] { display:none !important }) so el.hidden = true works on kit-styled controls |
sw-template.js |
network-first service worker — copy to the game, set cache name + shell list |
manifest-template.json |
PWA manifest starter |
tools/png.js |
dependency-free PNG encoder + shape painter for generating PWA icons |
tools/sync-to-game.js |
vendors the kit into a game's lib/ folder |
tools/stamp-version.js |
node lib/tools/stamp-version.js <game> --bump — bumps the -vN on the game's sw.js cache name and writes that number into index.html and version.json. Run it for every deploy instead of editing sw.js by hand |
tools/contrast.js |
WCAG 2.x contrast measurement — --css <file> audits a game's --gk-* palette. Button colours are a contrast decision; measure them rather than eyeballing |
Games vendor a copy (no CDN, no submodules): each game has a lib/ folder that is
an exact copy of gk/ plus tools/png.js, stamped with lib/VERSION. This keeps every
game self-contained (offline PWA caching keeps working) and makes kit updates deliberate —
a game only changes when you re-sync it and test.
# update a game to the current kit
node tools/sync-to-game.js "D:\OneDrive\Documents\Claude Code\chicken-cross"
# then test the game, stamp a new version, commit
node lib/tools/stamp-version.js . --bump # run inside the game directory
Load order matters (each file extends GK):
<link rel="stylesheet" href="lib/gk-base.css" />
<script src="lib/gk-util.js"></script>
<script src="lib/gk-audio.js"></script>
<script src="lib/gk-ui.js"></script>
<script src="lib/gk-storage.js"></script>
<script src="lib/gk-profiles.js"></script>
<script src="lib/gk-pwa.js"></script>
<script src="game.js"></script>// storage: pick a unique prefix + Firestore collection per game
const Storage = GK.createStorage({
prefix: "mygame",
collection: "mygame", // add a matching rule in Firestore first!
firebaseConfig: {...}, // same wordvoyage-e5a5c project, or null = offline-only
blankProgress: () => ({ coins: 0, best: 0, levels: {}, updated: 0 }),
mergeProgress: (a, b) => ({ // cross-device merge: keep the best of both
coins: Math.max(a.coins||0, b.coins||0),
best: Math.max(a.best||0, b.best||0),
levels: /* per-level best */ {...},
}),
});
// profiles + PIN + leaderboard
GK.Profiles.init({
storage: Storage,
avatars: ["🦊","🐼","🦄","🐸"],
meta: (p, prog) => `🏁 best ${prog.best}`,
onEnter: (p) => showMainMenu(p),
});
GK.Profiles.renderList(); // whenever the roster screen shows
// sounds: extend the defaults
Object.assign(GK.Sfx, { hop() { GK.Sfx.tone({ freq: 600, dur: 0.06 }); } });
// PWA (needs a #btn-install button, sw.js, manifest.json, icons/)
GK.initPWA({ appName: "My Game" });
// background sync; game is playable immediately
Storage.initFirebase().then(ok => badge.textContent = ok ? "☁️ synced" : "📴 offline");New-game checklist:
- Copy
demo/as a starting skeleton, or wire up as above node tools/sync-to-game.js <game>to vendorlib/- Copy
sw-template.js→sw.js(set cache name, ending in-v1, + shell),manifest-template.json→manifest.json, thennode lib/tools/stamp-version.js <game>to show the version on the home screen - Write an icon script against
lib/tools/png.js(see the games'tools/make-icons.js) - Add a Firestore rule for the new collection in the console
profile_<id>—{ id, name, avatar, pin, created, updated }progress_<id>— game-defined shape; must containupdateddeleted_<id>— tombstone so removed profiles can't be resurrected by stale devices
gk-storage.js signs in anonymously on boot, before touching Firestore, and
the rules require request.auth != null. The Firebase config is public in every
page load, so open rules meant anyone who viewed source could read or wipe the
family's progress. The anonymous uid is per-device and identifies nobody — it is
a gate against drive-by scripts, not an account, and players see no sign-in.
Sign-in failure is deliberately not fatal: if the Anonymous provider is ever turned off, games log a warning and carry on, and it is the rules that decide whether they can still sync.
The intended rules live in firestore.rules. That file is
documentation, not deployment — apply it in the Firebase console (Firestore
Database → Rules) or via firebase deploy --only firestore:rules.
Rollout order matters. Applying the rules before every game ships a build with anonymous auth will silently drop those games to localStorage-only:
- Enable Authentication → Sign-in method → Anonymous in the console.
- Ship gamekit ≥ 1.3.0 to every game (re-vendor, deploy).
- Only then apply the rules.
Data-safety tests for the persistence/sync core (gk-storage.js) — the code
whose bugs would silently corrupt the kids' saved progress. No framework, just
Node's built-in runner:
cd gamekit && node --test # or: npm test
tests/storage.test.js loads the real gk-* scripts in a vm sandbox with a
fake localStorage and a mock Firestore, and covers: tombstoned deletes (a
removed profile can't be resurrected by a stale device or a queued push), the
cross-device progress merge (keeps the best of each field, never drops an
unknown key), the newer-profile-wins rule, and the debounced write path (saves
coalesce and flush). The mergeProgress contract test also pins the "preserve
unknown keys" rule and the score-season (sver) reset pattern.
Open any game with ?debug=1 for the dev panel (gk-debug.js): FPS, plus
whatever toggles, actions and level-jumps that game registers. A normal player
URL has no query string, so none of it runs.
Debug mode never writes progress. Jumping levels or switching on invincibility would otherwise persist, and because family sync merges by MAX an inflated score is permanent on every device. Debug is an inspection mode — look at anything, change nothing. Pass the storage object so the kit can enforce it:
GK.Debug.init({ storage: Storage });
GK.Debug.toggle("hitboxes", "hitboxes");
GK.Debug.jump("level", LEVELS.length, (n) => App.startLevel(n - 1));
// game loop: GK.Debug.frame(dt);
// renderer: if (GK.Debug.flag("hitboxes")) drawBoxes();The games have no build step, so their tests load plain browser scripts via
tools/test-harness.js (loadScripts — vendored into each game at
lib/tools/test-harness.js). See docs/testing.md for the
harness, the data-linter pattern (including reachability BFS), and the headless
balance-bot pattern — the checks that catch "shipped but unfinishable / not
fun" before a game reaches the kids.
- Screens:
<div class="screen" id="screen-NAME">, one.active - Toast:
<div class="toast" id="toast"> - Profile roster container:
<div id="profile-list">(id configurable) - Install button:
<button id="btn-install" style="display:none"> - Admin PIN default
7777(configurable viaGK.Profiles.init({ adminPin })) el.hidden = truegenuinely hides — including.btn,.screenand the rest. Class rules like.btn { display:inline-flex }sit in the author origin and so beat the browser's UA[hidden] { display:none }; without the kit's!importantoverride a hidden button stays on screen and stays clickable. Aquamarine's Roll button gave a free reroll that way.