name: kc4-checklist description: Pre-implementation checklist for keyboard-command-4 sessions. Prints the coding rules most likely to cause bugs. Run this before writing any session code.
Read the session file, then confirm each rule below before writing code.
Spec Code Has Bugs — Fix Before Implementing
Session specs often contain code samples that violate CLAUDE.md rules. Before using any code sample from the spec, scan it for these patterns and replace them:
| Spec pattern | Fix |
|---|---|
style.display = 'none' / 'block' / 'flex' |
Use a CSS class instead (.active, .open, .hidden) |
onclick="..." HTML attribute |
Remove attr; bind with addEventListener in JS |
.onclick = () => ... |
Use addEventListener — no stacking risk |
.sort(() => Math.random() - 0.5) |
Fisher-Yates (see Shuffling section) |
color: #636e72 |
color: var(--text-secondary) |
localStorage.getItem / .setItem |
SaveManager.load() / SaveManager.save() with key keyboard-command-4-save |
window.x = window.x || new X() |
window.x = new X() in game.init() — always fresh |
new AudioContext() in constructor |
Create lazily on first _getCtx() call; ctx.resume().then(() => schedule()) |
{ "type": "Full Restore", ... } item |
Use { "type": "health", "amount": 50 } — only health and weapon are engine-supported item types |
{ ..., "combo": true } on a monster |
Remove — combo monsters are NOT implemented in the engine |
{ ..., "mode": "key" } on a monster |
Remove — mode field is not read by the engine; display mode is set by monster type |
"shortcut": ["cmd_a", "cmd_c"] on a monster |
Array shortcut fields are NOT implemented — use a single shortcutId per boss phase only |
Boss phase with shortcutCategory or shortcutLevelRange |
Not supported — every boss phase needs a fixed shortcutId that exists in shortcuts.json |
Level JSON Authoring Rules
Before writing any level JSON, verify these constraints against the engine:
Supported fields
- Items:
typeis"health"(+amount: number) or"weapon"(+weaponId: number). Nothing else. - Monsters: only
type,depth,offsetX. Nomode,combo,shortcutarray. - Boss phases:
shortcutId(fixed, must exist in shortcuts.json),instruction(must match that shortcut'sactionfield exactly),taunt(unique across all levels).
Item after_room safety — items near boss corridors are silently dropped
Find the first room with a boss field (first_boss_id). Then:
- If room
first_boss_id - 1hasisBonus: true→ safe max = first_boss_id - 3 - Otherwise → safe max = first_boss_id - 2
Items with after_room > safe_max are swallowed by startBossTransition and never delivered.
| Level structure example | Safe max |
|---|---|
| 5 rooms, boss at r5, no bonus | 5-2 = 3 |
| 6 rooms, boss at r6, no bonus | 6-2 = 4 |
| 6 rooms, bonus at r5, boss at r6 | 6-3 = 3 |
| 7 rooms, bonus at r6, boss at r7 | 7-3 = 4 |
| 6 rooms, first boss at r4 (multi-boss) | 4-2 = 2 |
Other invariants
- Boss
hpmust equalphases.length(hp is display metadata but must match) - No two monsters in the same wave may share
offsetX - Mage
depthmust be ≤ 0.15 (mages are stationary/ranged) - All monster depths in range 0.0–0.8
Run /verify-kc4-levels after writing all level JSON files
Canvas Performance Rules
This is a Canvas-rendered game targeting 60fps on iPad Safari. Every frame counts.
Offscreen Caching
- Room backgrounds are static — render once to an offscreen canvas on room load. Each frame just
drawImage()the cached background. - Monster sprites — pre-render each type + state to offscreen canvases on level load. Never draw from primitives each frame.
- HUD text — cache static elements. Only re-render score/health text when values change (dirty flag pattern).
No DOM During Gameplay
- All gameplay rendering is on Canvas — no
createElement,innerHTML,classList, ortextContentduring the game loop. - HUD overlay elements (health bar, score) are DOM but updated only on value change, not every frame.
Particle Pool
- Pre-allocate a pool of 50 particle objects. Reuse rather than create/destroy.
- Each particle:
{x, y, vx, vy, life, color, size, active}.
RAF Delta Time
- Always use
requestAnimationFramewith delta time for movement/animation. - If delta > 20ms (below 50fps), reduce particle count and skip non-essential animations.
- Never use
setIntervalfor the game loop.
Timer Lifecycle Pattern
Every manager that uses setTimeout or setInterval must follow this exact pattern. Missing any part is a bug.
class SomeManager {
constructor() {
this._mainTimer = null; // declare ALL timer IDs as null
this._shakeTimer = null; // even short cosmetic timers
this.onComplete = null;
}
cancel() {
clearTimeout(this._mainTimer); this._mainTimer = null;
clearTimeout(this._shakeTimer); this._shakeTimer = null;
this.onComplete = null;
}
complete() {
const cb = this.onComplete; // save BEFORE cancel() nulls it
this.cancel();
if (cb) cb();
}
start(data, onComplete) {
this.cancel(); // defensive reset on re-entry
this.onComplete = onComplete;
// ... initialize fresh state ...
}
}
Key rules:
- Every
setTimeout(...)→ store return value inthis._somethingTimer - Short cosmetic timers (screen shake, hit flash) still need IDs — they fire on detached DOM
cancel()is the single source of truth for cleanupstart()always callscancel()first
Input System Rules
Modifier Key Routing
The input system must distinguish between shortcut attempts and game controls:
- If
e.metaKey || e.altKey || e.ctrlKey→ route as shortcut attempt (compare against shortcut database) - If bare number key (1-0) with NO modifiers → route as weapon select
Tab/Shift+Tab→ target cycling (NOT browser tab navigation — mustpreventDefault())Escape→ pause menuSpace→ advance dialogueH→ shortcut journal
preventDefault() Scope
preventDefault()on ALL key events during gameplay state — prevents browser/OS shortcuts- In menu states (TITLE, LEVEL_SELECT, RESULTS), only prevent game-bound keys
- Some system shortcuts cannot be intercepted (Cmd+H, Cmd+Space on iPadOS) — these use "Knowledge Monster" variant
700ms Fire Lock
After a correct shortcut fires the weapon:
- Set
this._inputLocked = true this._lockTimer = setTimeout(() => { this._inputLocked = false; }, 700)- During lock, ignore all shortcut input (but allow Tab for target cycling)
- Store
_lockTimerand clear incancel()
Web Audio API (iOS Safari)
AudioContextmust be created lazily inside a user-gesture handler — never in a constructorctx.resume()returns a Promise — chain scheduling inside.then():_getCtx() { if (!this._ctx) this._ctx = new AudioContext(); return this._ctx; } playSound(type) { const ctx = this._getCtx(); ctx.resume().then(() => { // schedule oscillator nodes HERE, not before .then() }); }- Never schedule oscillator nodes immediately after
ctx.resume()— they may not play on iOS
Screen Visibility
- Screens →
.activeclass - Overlays (pause, settings, journal) →
.openclass - Any element toggled visible/hidden → a semantic CSS class — NOT
style.display
Modal Dialogs (Pause, Settings, Journal)
role="dialog",aria-modal="true",aria-label="..."- First focusable element = close button
- Focus trap: Tab/Shift-Tab cycles within overlay
- Escape to close (with
.contains('open')guard) - On open: focus close button,
setAttribute('aria-hidden', 'false')on overlay - On close:
setAttribute('aria-hidden', 'true'), return focus to trigger element aria-hiddenalways explicit'true'/'false'— neverremoveAttribute
State Machine
TITLE → LEVEL_SELECT → GAMEPLAY → TRANSITION → GAMEPLAY → ...
→ BOSS_FIGHT → LEVEL_COMPLETE → LEVEL_SELECT
→ PAUSE (overlay)
→ JOURNAL (overlay)
→ GAME_OVER → respawn or LEVEL_SELECT
- State transitions must be clean — cancel all active timers, animations, and input locks
showLevelSelect()must callcancel()on every active manager- Guard timer callbacks with null-checks:
if (!window.someManager) return; - Timer callbacks must tolerate PAUSED state. Use
state !== 'GAMEPLAY' && state !== 'PAUSED'— NOT just!== 'GAMEPLAY'. The user can pause during any delay (room-clear, wave spawn). Guarding only!== 'GAMEPLAY'causes permanent stalls. - When adding a new state, document: which states enter/exit it, what the game loop does during it, what input is accepted/blocked
Single RAF Chain — game.js Owns the Loop
IMPORTANT: Only game.js may call requestAnimationFrame. No manager may start its own animation loop.
Any class that renders to the game canvas must expose passive methods called by the game loop:
update(dt)orupdateX(now)— advance internal statedraw(ctx, w, h)ordrawX(ctx, w, h, now)— render to canvas
// WRONG — manager owns its own RAF chain → flickering
class SomeManager {
start() { this._raf = requestAnimationFrame(() => this._tick()); }
}
// CORRECT — game loop drives the manager
if (this.state === 'TRANSITION') {
this._levelManager.updateTransition(now);
this._levelManager.drawTransition(ctx, w, h, now);
}
Batch SaveManager Operations
When updating 2+ save fields at once, do a single load() → modify → save():
// WRONG — 3x redundant JSON.parse + JSON.stringify
SaveManager.saveLevelResult(levelId, stats);
SaveManager.unlockWeapon(weaponId);
SaveManager.updateSettings('hints', 'always');
// CORRECT — single round trip
const data = SaveManager.load();
data.levels[String(levelId)] = { /* ... */ };
if (!data.weaponsUnlocked.includes(weaponId)) data.weaponsUnlocked.push(weaponId);
data.settings.hints = 'always';
SaveManager.save(data);
Timing Constants Must Be Synchronized
These durations are coupled across files. Changing one without the other is a bug:
| Constant | Value | Files |
|---|---|---|
| Input lock after fire | 700ms | input.js, weapons.js |
| Monster death animation | 500ms | monsters.js, renderer.js |
| Corridor transition | 500ms | levels.js |
| Boss corridor | 800ms | levels.js |
| Boss title card | 2000ms | levels.js |
Session Spec Validation
Before implementing any session plan, check these:
- Default settings must have a rationale. Every user-facing default (hints, volume, speed) needs justification.
hints: 'always'= "new players can learn"; not just a value with no context. - Canvas rendering ownership must be stated. If the plan says "render X to canvas", confirm it says game.js calls the render method — not "the new class renders to canvas" (which implies its own RAF).
- New state machine states must document transitions. Which states enter it, which states it exits to, game loop behavior, input handling.
- Scan spec code samples against the pattern table above. Specs routinely contain
style.display,.onclick, independent RAF chains,new AudioContext()in constructors.
Monster Depth System
- Depth 0.0 = back of room (small, far away)
- Depth 1.0 = front of room (large, at attack range)
- Render order: back-to-front (lower depth first)
- Scale factor:
0.3 + depth * 0.7(30% at back, 100% at front) - Y position: maps depth to Canvas Y coordinate (higher on screen = further away)
Save System
- One key:
keyboard-command-4-save— always through SaveManager - Never call
localStorage.getItem/setItemoutside SaveManager - Auto-save after: room clear, boss defeat, settings change
SaveManager._defaults()must include ALL keys that game.js reads from save data
Shuffling
Never array.sort(() => Math.random() - 0.5) — biased. Use Fisher-Yates:
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
WCAG Non-Negotiables
- Never
user-scalable=noin viewport meta - Touch targets >= 44x44px on all interactive elements (menu buttons, level cards)
- Font: OpenDyslexic via CDN
<link>, Comic Sans MS fallback — never@importin CSS - Min font size: 16px, scalable (16/18/22px)
- Background: cream
#F5F0E8, text:#2C2416, secondary:#595143 - WCAG AA contrast 4.5:1 minimum — never use
#666,#888,#999on cream - No flashing/strobing effects — death animations use fades
- Screen shake: 100ms maximum, subtle
CSS ID Specificity
ID selectors (#foo) always beat class selectors (.bar). Never put display: in a base ID rule when a class like .screen controls visibility:
/* BAD — #screen-title display:flex overrides .screen { display:none } */
#screen-title { display: flex; }
/* GOOD — only active screens get display:flex */
#screen-title.active { display: flex; }