---
name: game-prototype
description: Build a browser game that is PLAYABLE — engine or canvas, fixed timestep, held-key input, collision, menus, pause, sound
when: A game, playable prototype, arcade demo, platformer, shooter, physics toy, or a canvas with a score
---

# Game prototype

The target is a game a human can **pick up and play**.

⚠️⚠️ **The failure this skill exists to stop:** the generated "game" that renders,
animates and cannot be played — no lose condition, no restart, no pause, a player
that phases through walls. It looks finished in a screenshot and dies the first
time someone presses a key.

## ⭐ First decide: engine, or hand-rolled canvas? — then read `game-engines`

**An engine** for an arcade game, a platformer, a shooter, or anything needing
real physics (stacking, ragdolls, joints — never hand-write a solver). Engines are
pre-hosted and cost ~50 bytes, but reaching one has traps that ship a blank page:
**read `game-engines` before the script tag.**

**Hand-rolled canvas** for a toy, a visualiser, one mechanic, a jam-sized idea, or
whenever you cannot confirm an engine is reachable.

⚠️ **Section B applies either way.** An engine gives you the loop and the input
plumbing; it does not give you a lose condition, a pause screen, a restart, feel
or sound — and those decide whether the thing is playable at all.

---

# A. If you hand-roll it — the three things that must be right

## A1. The loop — fixed timestep, or the game runs at the wrong speed

⚠️ **The most common defect of all: moving by a constant per frame.** `x += 5`
inside `requestAnimationFrame` is 60 steps/sec on one monitor and **144 on
another** — 2.4× faster on better hardware, and it looks fine on yours.

```js
const STEP = 1 / 60;              // seconds of simulation per tick
let acc = 0, last = performance.now() / 1000;

function frame(nowMs) {
  const now = nowMs / 1000;
  // ⚠️ CLAMP. Alt-tab away for 10s and dt is 10 — without this the next frame
  // runs 600 physics ticks at once and the browser locks up.
  acc += Math.min(now - last, 0.25);
  last = now;
  while (acc >= STEP) { update(STEP); acc -= STEP; }
  render(ctx);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```

Everything in `update(dt)` is now **units per second**: `x += vx * dt`, gravity
`vy += 1400 * dt`. Never read the clock inside `update` — `dt` is the only time it
may see, and that is what makes it replayable.

## A2. Input — the event says WHEN, a Set says WHETHER

⚠️ Acting inside `keydown` gives you the OS key-repeat: one move, a ~500ms pause,
then a stutter. That is why the ship "sticks".

```js
const held = new Set(), pressed = new Set();
addEventListener('keydown', (e) => {
  // ⚠️ Without this, arrows and space SCROLL THE PAGE while you play.
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
  if (!e.repeat) pressed.add(e.key);   // edge: jump, fire, confirm
  held.add(e.key);                     // level: walk, thrust
});
addEventListener('keyup', (e) => held.delete(e.key));
// ⚠️ Alt-tab while holding Right and keyup never arrives — you run forever.
addEventListener('blur', () => { held.clear(); if (state === 'playing') state = 'paused'; });
```

`pressed` is cleared at the END of each `update`. Level-triggered (`held`) for
movement, edge-triggered (`pressed`) for actions — mixing them is why a jump
fires 30 times a second.

⭐ **Support a pointer too.** `pointerdown`/`pointerup` on the canvas, never
`click` (it fires after release and feels 100ms late). Ten lines, and the
difference between "playable" and "playable if you own a keyboard".

## A3. Collision — AABB first, and mind the tunnel

```js
const hit = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x &&
                      a.y < b.y + b.h && a.y + a.h > b.y;
const hitC = (a, b) =>                                  // circles, no sqrt
  (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < (a.r + b.r) ** 2;
```

⚠️ **Tunnelling.** A bullet at 900px/s moves 15px per tick; against a 10px wall it
is on one side before the tick and past it after, so the overlap test never sees
it and collision "randomly doesn't work". Either **substep** the fast body
(`Math.ceil(speed * dt / minThickness)` pieces, testing each) or **sweep** the
union of the before- and after-rects.

**Resolve one axis at a time**, or a player against a floor jitters into walls:

```js
p.x += p.vx * dt;  for (const s of solids) if (hit(p, s)) { p.x -= p.vx * dt; p.vx = 0; }
p.y += p.vy * dt;  for (const s of solids) if (hit(p, s)) {
  if (p.vy > 0) p.grounded = true;              // landed
  p.y -= p.vy * dt; p.vy = 0;
}
```

⚠️ Past ~200 entities stop testing every pair — bucket by `(x/64|0)+':'+(y/64|0)`
and test the 9 neighbouring buckets only. Twenty lines, turning an O(n²) freeze
into a game.

---

# B. Needed either way — engine or not. This is where prototypes are won and lost.

## B1. State — an explicit machine, a PAUSE, and a RESTART key

⚠️ **The corpse demo:** the player dies, everything freezes, and the only way to
play again is to reload. Whoever is evaluating it closes the tab.

```js
let state = 'menu';   // 'menu' | 'playing' | 'paused' | 'dead' | 'won'
function reset() { /* rebuild EVERY mutable thing from scratch */ }

function update(dt) {
  if (pressed.has('p') && (state === 'playing' || state === 'paused')) {
    state = state === 'playing' ? 'paused' : 'playing';
  }
  if (state !== 'playing') {
    if (pressed.has(' ') && state !== 'paused') { reset(); state = 'playing'; }
    pressed.clear();
    return;                              // ⚠️ nothing simulates when not playing
  }
  ...
  pressed.clear();
}
```

`reset()` must **rebuild** arrays, not empty-and-refill some. A restart leaving
last round's enemies alive only shows on the **third** playthrough.

⚠️ Pause stops the *simulation*, not the loop. Keep rendering and draw a "PAUSED"
overlay: a frozen black screen is indistinguishable from a crash.

## B2. The screens — a game with no menu is a demo

Three overlays over the same canvas, driven by `state`:

- **Menu** — title, one line of *how to play*, "Press SPACE to start".
- **Pause** — "PAUSED · P resume · R restart".
- **Over / won** — the score, the **best** score (`localStorage`; `web-app-quality`
  has the try/catch and versioned key), the restart key.

⭐ **Write the controls on the menu screen.** Undiscoverable controls make a game
unplayable however good the code is.

**HUD:** score, lives and the goal, drawn **last** — paint order is draw order, so
a HUD drawn first is a HUD behind the background. A player who cannot see why they
lost thinks the game is broken.

## B3. Feel — the ten lines that separate a toy from a tech demo

Do these before adding a second enemy type:

- **Coyote time** — allow a jump ~100ms after leaving a ledge.
- **Jump buffer** — honour a jump pressed ~120ms before landing.
- **Hit pause** — skip the simulation (not the loop) ~70ms on a kill.
- **Screen shake** — decaying, on the camera and never on positions.
- **Death particles** — twelve `{x,y,vx,vy,life}` objects and a fade. The best
  ratio of perceived quality to lines of code in the whole file.

⭐ **Once it plays, read `game-feel`** — the actual constants for all of the above
(trauma², hitstop ms, easing curves, particle counts), the sprite-sheet and HUD
craft, and the juice that costs frames and earns nothing.

## B4. Art without an artist

⚠️ **Do not wait for sprites and do not generate a spritesheet** — generated sheets
come back misaligned and cost a round each. What renders well:

- Primitives on a **committed palette**: 4–6 colours, one accent for the player,
  one for danger (`colour-and-contrast`).
- Emoji as sprites — `ctx.font = '32px serif'; ctx.fillText('🚀', x, y)`. Free,
  sharp at any size, instantly readable.
- With a real sheet, ⚠️ await `img.decode()` first and hold a `'loading'` state:
  `drawImage` on a half-loaded `Image` silently draws nothing. `game-feel` has
  the frame maths and the texture-bleed fix.

## B5. Sound — and why it works for you and is silent for everyone else

⚠️⚠️ **Browsers block audio until the user has interacted with the page.** An
`AudioContext` made on load starts `suspended`. Your machine carries a gesture
from before the reload; a fresh visitor's does not. *Always* the cause of "the
sound works locally".

```js
let ac = null;
const unlock = () => { ac ??= new AudioContext(); if (ac.state === 'suspended') ac.resume(); };
addEventListener('pointerdown', unlock, { once: true });
addEventListener('keydown', unlock, { once: true });

function blip(freq = 440, dur = 0.08) {          // no asset files at all
  if (!ac) return;
  const o = ac.createOscillator(), g = ac.createGain();
  o.frequency.value = freq; o.type = 'square';
  g.gain.setValueAtTime(0.15, ac.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + dur);
  o.connect(g).connect(ac.destination); o.start(); o.stop(ac.currentTime + dur);
}
```

Three sounds carry a prototype: jump (rising), pickup (two notes), death (falling).
⭐ Ship a **mute key** — an unmutable game gets closed, not muted.

## B6. Camera and spawning, once the level outgrows the screen

- **Camera** = one `ctx.translate(-cam.x, -cam.y)` inside `save()`/`restore()`,
  HUD drawn *after* `restore()` so it does not scroll away. Ease toward the player
  (`cam.x += (target - cam.x) * 0.1`) and **clamp to the level bounds**.
- **Pool; never allocate in the loop** — `bullets.push({...})` at 60Hz is a GC
  stutter you will blame on the physics. Flip an `alive` flag and sweep once per
  tick with `filter`. ⚠️ Never `splice` inside a `for` loop that indexes it: it
  skips the next entity, reading as "sometimes an enemy survives".

---

## Verify it — this is the step that gets skipped

1. Write the file(s).
2. **Open it in a real browser** — `playtest` if you have it, otherwise the manual
   loop in `check-the-site-you-built`. It returns MEASURED problems, not guesses.
3. ⚠️⚠️ **A canvas game with a JS error still renders a canvas.** "Console error"
   plus a black rectangle IS the bug. With an engine that black rectangle is more
   often the script tag than your game — `game-engines` has the check.
4. **Drive it.** Press start, left, fire. A game nobody pressed a key on has been
   looked at, not tested.
5. **Play it three times.** Once to see it work, once to see the restart work, and
   once because a `reset()` that misses a field only fails on the third run.
6. **Answer these before handing it over**, then state the controls: can the
   player LOSE — what ends the run? Can they WIN, or does the score climb forever?
   Which key restarts, and is it on screen? Does P pause, and does the screen say
   so? What happens at 390px wide, and on a touch screen?

## Canvas hygiene that bites once and confuses for an hour

- **HiDPI**: `canvas.width = cssW * devicePixelRatio` and `ctx.scale(dpr, dpr)`,
  or everything is blurry on every modern screen.
- ⚠️ **Setting `canvas.width` resets the ENTIRE context state** — transform, font,
  `imageSmoothingEnabled`. Re-apply after every resize. Pixel art needs
  `imageSmoothingEnabled = false` *and* CSS `image-rendering: pixelated`;
  `game-feel` has the texture-bleed fix, `animation` the ResizeObserver.

## ⚠️ What this cannot do — say so rather than pretend

- **No multiplayer netcode, and not Godot/Unity.** Ask, rather than ship a
  desyncing fake or an export whose COOP/COEP headers nothing here will serve.
- **You cannot feel it.** The browser proves it renders and the console is clean;
  it cannot tell you the jump is floaty. Say which of the two you verified.
