# Browser Game Development

> Develop and debug standalone single-file HTML/JS browser games (idle/tycoon/sim games persisted to localStorage). Covers the recurring bug classes — especially save-format forward-compatibility — and the patterns that prevent them.

- Skill: `wcpaka-lgtm/browser-game-development` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add wcpaka-lgtm/browser-game-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wcpaka-lgtm/browser-game-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: wcpaka-lgtm (https://skillmd.com/u/wcpaka-lgtm)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/wcpaka-lgtm/browser-game-development

---


# Standalone HTML/JS Browser Game Development

Use when building or fixing a self-contained browser game — a single `.html`
file with inline CSS+JS, no server, state persisted to `localStorage`. Typical
for idle/tycoon/colony/sim games generated with a coding agent and iterated on
over many sessions. The defining hazard of this genre is **save-format drift**:
the game ships a new version, but the player's `localStorage` still holds a save
from an older version that is missing fields the new code reads.

## The #1 bug class: save-format forward-compatibility

When you add ANY new field to the game state object, old saves do not have it.
The first render/tick function that reads `S.newField.something` throws a
`TypeError` on `undefined`, and because these games render by rebuilding a whole
tab's innerHTML in one function, **one missing field silently kills an entire
tab or halts the whole game loop** — the user sees a blank tab and reports
"the X tab doesn't open."

The fix is a single migration function that back-fills every field with a safe
default, called from BOTH the load-from-localStorage path AND the
import-from-file path. Full pattern + a real crash-chain walkthrough in
`references/save-migration.md`. Rules:

- One `migrateSave(s)` that sets a default for EVERY field the state can have
  (scalars, arrays, nested objects, per-resource maps, per-system price maps).
- Call it in `load()` and in the save-import handler — never trust raw parsed
  JSON to have all fields.
- When you add a state field, add its default to `migrateSave` in the SAME edit.
  Forgetting this is the bug. Make it a checklist item.
- Optionally stamp a `S.ver` and branch migration by version for big changes.

## Recurring bug catalog (check all of these on any fix pass)

Confirmed in real games; each has bitten a shipped build:

1. **Missing save field → render cascade crash.** See above. The headline bug.
2. **innerHTML re-render destroys per-element onclick handlers.** The game loop
   calls `renderStation()` / `renderShip()` etc. which rebuild the entire view
   via `innerHTML = ...`, then re-bind `.onclick` on each button. During active
   gameplay (speed > 0), the loop re-renders every frame or on every auto-sell
   tick, so the user's click lands on a button whose handler was just destroyed
   and not yet re-attached. **Symptom**: "buttons work when paused but not at
   1x–3x speed." **Fix**: replace per-element `.onclick` with **event delegation**
   — one `addEventListener('click', ...)` on the parent container (`#view-station`,
   `#view-ship`, etc.) using `e.target.closest('[data-action]')`. Delegated
   listeners survive any innerHTML replacement. Apply to ALL interactive views.
   Also delegate `change` events for file inputs (`importSave`). Full pattern in
   `references/event-delegation.md`.
3. **Fractional resource accumulation.** Production like `count * 1.5` yields
   fractional resources that display as `5676.5` and break `cargoUsed` sums.
   Wrap production in `Math.round()`; floor when summing for capacity checks.
4. **Offline/idle progress ignores caps AND queues.** "Welcome back, +N days of
   production" dumps resources past the cargo/storage cap — clamp each added
   amount to remaining capacity. Separately, players expect build/train queues
   and army travel to fast-forward too: complete any queue items whose
   `startTick + duration` falls inside the offline window, and mention
   completions in the welcome-back message.
   ⚠️ **Do NOT shift pending army/raid deadlines forward by the elapsed time.**
   Pushing `arriveTick`/`returnTick` (and `pendingAttacks`) forward by elapsed
   keeps their offset from the game clock identical, so combat/conquest/tribute
   that should have resolved *during the absence* never resolves — it's silently
   delayed forever (and the player returns expecting a finished battle). Instead,
   advance the game clock and let the deadlines resolve naturally on the next
   tick(s); add a note to the welcome message like "N arrivals resolving now".
   Confirmed as a real shipped bug and fixed in a cross-validated review loop.
5. **Unclosed container div in a render function.** innerHTML builders that
   forget a closing `</div>` break the layout of everything after that card.
   Count your opens/closes when a panel looks wrong.
6. **Null-state handlers before init.** Buttons wired at load time
   (`el.onclick = () => S.x = ...`) crash if clicked before the game starts
   (`S === null`). Guard with `if (!S) return;`.
7. **Game loop halts on undefined speed/flag.** `if (S.speed > 0)` is `false`
   when `speed` is `undefined`, so an old save permanently freezes the sim.
   Migration defaulting `speed:1` fixes it; also re-sync UI toggle buttons to
   the loaded value on start.
8. **Resource cap too small → "can't do anything" frustration.** If the cargo/
   storage cap fills in seconds without auto-sell, the player is locked out of
   all other activities. Fix: set initial cap high enough for minutes of idle
   play (e.g. 5,000+), and make per-upgrade increments large (+2,000 not +25).
   Recalculate in migrateSave from level: `s.cargo = BASE + level * INCREMENT`.
9. **Re-render wipes user-typed input values every tick.** Cousin of bug #2:
   the loop rebuilds a view's innerHTML each tick, so a number input the user
   is typing into (train count, army size) resets to its default value 1–2 s
   after they type — the user literally cannot set it. Event delegation (#2)
   fixes clicks but NOT input contents; the DOM node holding the typed value
   is destroyed. Two-part fix: (a) `saveInputs()`/`restoreInputs()` around
   every innerHTML rebuild of an input-bearing view — snapshot id → value +
   focus + caret, re-apply after; (b) split rendering into a cheap per-tick
   `updateDynamic()` (topbar numbers, progress bars, timers only) and a full
   `renderAll()` gated by a `needsFullRender` flag set only on structural
   changes (queue completes, army returns, combat). Full code in
   `references/input-preservation.md`.
10. **three.js GPU leak on scene rebuild (3D builds).** Every rebuild path that
    removes and recreates objects — building models swapped on level-up, world
    map markers rebuilt, warning rings regenerated, army-mesh pool resized,
    particle resets — must dispose GPU resources BEFORE `group.remove()`,
    otherwise every rebuild leaks buffers and a long idle session (hours at
    high speed) grows memory unbounded. Centralize one helper and call it at
    EVERY rebuild site:
    `function disposeObject(root){ root.traverse(function(o){ if(o.geometry)o.geometry.dispose(); if(o.material){(Array.isArray(o.material)?o.material:[o.material]).forEach(function(m){m.dispose();});} }); }`
    Audit checklist: building swap, marker rebuild, ring regen, mesh-pool
    shrink (`while(pool.length>n){ disposeObject(pool.pop()) }`), smoke/particle reset.
11. **Capacity checks ignore queued reservations.** Validating an order against
    a cap (population, housing) by reading only *produced* units lets rapid
    repeated orders each pass individually and overshoot the cap. Count the
    queue too: `getPopUsed() + getQueuedPop() + n*pop > cap` where
    `getQueuedPop()` sums `q.count * pop` over the pending train queue. Same
    principle for any cap that queue items will eventually consume.
12. **Stale-target race at resolution time.** Any action that resolves after a
    delay (army arrival, queued order) must re-check the target's CURRENT
    ownership/state at resolution, not trust what was true at dispatch. Real
    case: two armies targeted the same village in the same tick — army A
    conquered it (`type` flipped to `player`), then army B's arrival handler
    looked the target up by coordinates only and attacked the PLAYER'S OWN
    territory, looting themselves. Fix: in the arrival branch,
    `if(target.type==='player'){ return home (log it); } else resolveCombat()`.
    Same rule for any queue: validate the target is still actionable when the
    timer fires, and pick a graceful fallback (turn back, refund, cancel).
13. **Background-tab throttling erases game time AND blocks offline
    compensation.** Browsers throttle background tabs' `setInterval` to ~1/min;
    a fixed-step tick advances one step per callback, so 10 background minutes
    ≈ a handful of ticks. Worse, each throttled callback refreshes
    `S.lastTick=Date.now()`, so `applyOfflineProgress()` sees a small gap on
    return and compensates nothing — lost time is permanent. Fix inside the
    interval, before `tick()`: `gap=(Date.now()-S.lastTick)/1000; if(gap>=5 &&
    S.speed>0) applyOfflineProgress();` (or a delta-based tick). Optionally
    also hook `visibilitychange`.
14. **Silent localStorage save failure.** `try{setItem}catch(e){}` swallows
    quota-exceeded / private-mode errors; the player plays hours, closes the
    tab, loses everything. Fix: catch → `console.warn` + ONE-time warning
    toast guarded by a `saveWarned` flag.
15. **Trades destroy the overflow above the cap.** Pattern
    `res[from]-=amount; res[to]=min(cap, +got)` deducts full payment then
    clamps the receipt — the difference vanishes when the destination is near
    cap. Validate `res[to]+got <= cap` BEFORE deducting; refuse with a clear
    message instead of half-committing. Same audit for merchant accepts and
    quest rewards.
16. **Corrupt-but-parseable saves black-screen the game forever.** Migration
    fills missing fields but cannot reconstruct structural invariants — e.g. a
    save whose map has no player home village crashes every home-lookup
    forever. `load()` must validate invariants (resources present, map
    non-empty, home exists) and return `null` → fresh state, not boot broken.
17. **Ticker/log dedupe by timestamp drops same-instant entries.**
    `if(entry.time<=lastShown)return` skips distinct entries created in the
    same millisecond (batched events within one tick). Dedupe on a monotonic
    sequence instead: `S.logSeq++` in `addLog`, persisted, defaulted in
    migrate.
18. **Delayed/queued actions must be reversible — and the reversal must be
    physically honest.** Two recurring player-frustration gaps in strategy games:
    (a) **Queue cancellation** — build/train queues deduct resources up front but
    offer no cancel. Add a per-item ✕ that removes the slot, refunds the FULL
    cost (clamped to the storage cap), and then **re-chains the remaining items'
    `startTick`** (`prevEnd = startTick+duration` walks down the list) so later
    items don't keep a stale gap. (b) **Army recall** — an army in the outbound
    phase should be recallable; the return deadline must be
    `returnTick = now + (now - startTick)` (distance already travelled), NOT
    `now + (arriveTick - startTick)` (full round-trip) — the latter charges the
    player double and feels wrong. Got this wrong once (the verify assertion
    encoded the round-trip expectation); the travelled-distance version is the
    honest one.
19. **Autosave cadence keyed to game ticks varies with speed.**
    `if(S.tick % 30 === 0) save()` writes ~100KB JSON every 3 real seconds at
    speed=10 but only every 30s at speed=1. Key saving to wall time instead:
    `if(Date.now()-S.lastSave>=30000) save()` (update `S.lastSave` in `save()`).
    Also add `window.addEventListener('pagehide', function(){ try{save();}catch(e){} })`
    — without it, closing the tab silently loses up to a full autosave interval.
20. **Shared modal overlay + timer expiry = unrelated modal gets nuked.**
    Single-file games typically have ONE modal container; if a background timer
    (merchant offer expiring, event firing) calls the generic close, it also
    kills whatever the player deliberately opened (a battle report, a trade
    screen). Fix: the expiry path must be SCOPED — close only if the modal
    currently showing is the one that owns the timer (check a marker string in
    the modal innerHTML or track an `openModalId`), otherwise leave it open
    and surface the expiry as a toast/log. Game-logic side: don't call a
    generic `uiModalClose()` from a timer — emit a specific bridge event
    (`uiMerchantExpired()`) and let the UI decide.
21. **Batch queues hold large orders hostage; stream them instead.** A train
    order of N units that completes only when the FULL `duration = unitTime*N`
    elapses means a big order delivers nothing for minutes while raids keep
    killing troops ("병력이 안 모이고 계속 줄어" — net-loss death spiral).
    Users expect streaming production: 1 unit finishes every `unitTime`.
    Pattern: queue item gets `unitTime`, `delivered` (delivered so far)
    alongside `count`/`startTick`/`duration`. Every tick:
    `totalDone = clamp(floor((tick - startTick)/unitTime), 0, count)`;
    deliver `totalDone - delivered` fresh units; shift the item only when
    `totalDone >= count`. Four places must ALL honor streaming: (a) the live
    tick loop, (b) offline catch-up (deliver everything that finished within
    the offline window, count them into the welcome message), (c) cancellation
    refund (`count - delivered` remaining units only; already-delivered units
    are spent), (d) queued-cap accounting (`getQueuedPop()` must sum
    `(count-delivered)*pop`, not `count*pop`). UI shows a live
    `delivered/count` counter updated from the bar-update pass.
22. **Troop economy: train rate must outpace raid losses.** When players
    report "troops never accumulate," it's an arithmetic problem, not a bug:
    units/min trained < units/min killed by raids. Levers, in order: cut unit
    train time (`pop*2 + ironCost/60` scale, floor 2s), push the first forced
    raid out (~tick 480–600 instead of 240–320), lengthen the AI attack
    period (30→45 ticks), and soften per-conquest aggro (0.08→0.05). Then
    re-run the balance sim — the "loss rate >70%" warning is the regression
    signal for this class. Full checklist in `references/feature-expansion.md`.

## Workflow for a "fix my game" request

1. Reproduce in a real browser first (open the file, click the reported tab,
   read the console) — confirm the symptom before touching code.
2. Read the FULL source. These are one file; read all of it. Trace the reported
   symptom to the exact function and the exact field access that throws.
   ⚠️ When planning a batch of improvements, **verify each one against the
   actual code BEFORE writing patch tuples**. A feature the user mentions may
   already be implemented (e.g. auto-mining was already in `loop()` but the
   user didn't know the tech name). Grep for the feature's key identifiers
   first; skip patches for things that already exist.
3. Back up the file (`cp game.html game.html.bak`) before editing.
4. Apply targeted patches (string-replace), not a rewrite — preserve the user's
   save key and overall structure. For a multi-patch pass, drive it from a
   script with a list of `(old, new, description)` tuples: apply each, report
   ✅/❌ per patch, and **refuse to write the file if ANY tuple fails to match**
   — a partial write leaves the game half-migrated and harder to diagnose than
   a clean abort. Fix the missed tuple's exact string (re-read the real file;
   an earlier patch in the same batch may have shifted the text) and re-run.
   ⚠️ **Stale-file trap**: when you refuse to write, the successful patches in
   that batch are LOST — they only existed in the in-memory string. Your retry
   batch applies to the ORIGINAL on-disk file. You must re-apply ALL patches
   (the previously-successful ones AND the newly-fixed ones) in the retry run.
   This bit us twice in one session: batch 1 had 11/14 pass → not written;
   batch 2 fixed the 3 failures → written; but the 11 from batch 1 were gone.
   Solution: keep the full tuple list and re-run it whole after fixing failures.
5. Verify: syntax-check the script, then unit-test the pure logic functions
   (especially `migrateSave`) against a synthetic OLD save missing the new
   fields. See the `headless-html-testing` skill — for an isolated pure function
   you can extract just it with `new Function(...)` and test directly, no full
   DOM stub needed. On Windows git-bash, `node --check /tmp/x.js` and `$TEMP`
   paths don't resolve (MSYS translation) — extract to a relative `./_check.js`
   in the working directory and delete it afterwards.
6. Deliver a short bug table (what broke / why / fix) so the user sees value.

## Multi-base expansion ("let me build/train/attack from captured villages")

When the user wants conquered/captured locations to become real playable bases
(outposts, colonies, second cities), the single-home assumption is baked into
a dozen call sites. A safe expansion pattern, proven end-to-end:

- **Lazy per-village structure.** Give each player-owned non-home village an
  `outpost` object `{buildings:{...}, units:{...}, buildQueue:[], trainQueue:[]}`
  created on demand by `getOutpost(v)` (never trust old saves). `migrateSave`
  must create it for every existing captured village when bumping the save
  version.
- **Accessor indirection instead of `S.units`/`S.buildings` reads.** Add
  `getVillageUnits(v)` / `getVillageBuildings(v)` returning the home arrays or
  the outpost arrays; rewrite combat, training, population, and queue code to
  go through them. Grep every literal `S.units` and `S.buildings` use and
  decide per-site whether it's home-only or village-generic.
- **Origin-aware actions.** `sendArmy(..., originX, originY)` — units leave
  FROM and RETURN TO the village they marched out of (not always home). The
  arrival/return handler must look up the origin village and handle it having
  changed ownership meanwhile (see bug #12). UI: an origin-selector row
  (home + outposts with the prerequisite building) above the dispatch form.
- **Outpost-scoped queues.** Build/train queue processors, offline catch-up,
  cancellation, and capacity checks all need the outpost branch — these are
  the call sites everyone forgets; walk the full list in
  `references/feature-expansion.md`.
- **Caps go global, prerequisites go local.** Population cap sums home farm +
  outpost farms (a real reward for expansion), but unit unlock requirements
  (`req:{barracks:3}`) check the ORIGIN village's buildings, not home's —
  otherwise outposts train anything from tick one.
- **Threat model updates.** AI raids should target the nearest player village
  (home or outpost), not always home; a raid whose origin village was captured
  meanwhile cancels gracefully.
- **Verify with a scripted capture.** In the verify script, force a conquest
  (`v.type='player'` + `v.outpost={...}`), then assert build → train → stream
  → dispatch → return-to-origin all work on the outpost AND home still works
  (backward compat). Include a synthetic old-version save in the migration
  checks.

## When the user says "it's not fun"

"Not fun" in a strategy/sim game almost always means **no meaningful choices**,
not "not enough content." Diagnose by checking for these missing elements (in
impact order):

| Missing element | Why it matters |
|-----------------|---------------|
| Random events | Every session should feel different; "something happens" every 60-90s |
| Quests/goals | Player needs direction + dopamine from completion rewards |
| Tactical combat | Attack vs defense division is boring; formations, rounds, and logs add agency |
| Tech/era progression | "What unlocks next" is the core progression hook |
| AI threat | If the AI never attacks, there's no tension; raids must be telegraphed but real |
| Resource trading | Surplus/deficit creates economic puzzles |

Fix in this order — each one independently testable via the headless balance
sim (see `headless-html-testing` skill, "Balance simulation harness" section).

## AI threat tuning (strategy games)

⚠️ **These knobs are bidirectional.** The defaults below assume the common
failure mode — AI that never attacks. If the complaint is the OPPOSITE
("raids more frequent than training, troops never accumulate"), reverse the
direction: push the first raid LATER, lengthen the period, lower aggro.
Bug-catalog #22 and `references/feature-expansion.md` §2 carry the
relaxation-side values.

AI that never attacks makes the game a spreadsheet. Tuning knobs:

- **Raid period**: 45-60 ticks (not 90+). Shorter = more pressure.
- **Base aggro**: 0.25-0.55 random range (not 0.15-0.40). Higher = more attacks.
- **Min force threshold**: 3-4 units (not 6+). Early-game AI villages have few
  units; a high threshold means zero raids for the first 20 minutes.
- **First-raid guarantee**: Force one attack around tick 180-240 if no raids
  have occurred yet. Prevents the "40 minutes of peace" anti-pattern.
- **Conquest-scaled aggro**: `effectiveAggro = base + conquered * 0.08`.
  Punishes expansion — the more you take, the more they come.
- **Telegraphed attacks**: Show "X is gathering forces, arriving in N seconds"
  before the raid lands. Gives the player time to react (train defenders,
  recall army). This is what makes raids FEEL like threats rather than random
  damage.
- **AI growth**: AI villages should regen units every cycle, slightly faster
  when the player is stronger. Keep regen MODEST — `ceil(level*0.15*grow)` per
  unit type with `grow = playerStrong ? 1.15 : 1.0`. Earlier advice of
  `level*0.3` with `grow 1.3–1.8` was empirically broken: a level-5 village
  then restored ~13+ units/min, faster than a round-trip expedition, so
  conquering distant/high-level villages became a structurally unwinnable war
  of attrition. Regen must be slower than a focused player army can remove.
- **Loot/carry balance**: If loot per raid is trivially small (e.g. 158 per
  resource after a 60-min sim), the player has no economic incentive to fight.
  Target: carry values of 30-100 per unit type, and make the LOOT SUM match
  total carry — set per-resource loot to `carry * 0.25` when there are 4
  resources (NOT 0.5, which inflates total loot to 2× carry and doubles the
  effective carry stat). Caps scaled to target level (e.g. 800 × level).
- **Combat rounds vs conquest**: with a fixed kill ratio per round, N rounds
  leaves `survivor ≈ (1-k)^N` of the garrison. 3 rounds at k≈0.55 leaves ~9%
  alive, so single-battle conquest is nearly impossible even when
  overwhelming. 4 rounds leaves ~2% — enough for decisive victories while
  keeping upsets possible. Tune rounds together with AI regen: if regen is
  fast, even a 95% wipe recovers before the player returns.

## three.js 3D upgrade ("the UI is too plain")

Trigger: user says the flat HTML version looks too simple / wants richer
graphics. For this user, three.js is the DEFAULT for all game requests unless
they say otherwise — don't ask, just build in 3D.

**Architecture — still ONE html file, but split into two scripts** so the
headless playtest harness keeps working unchanged:

1. `<script id="game-logic">` — ALL game state + logic, ZERO DOM access.
   UI callbacks go through a bridge: `function uiToast(m,t){ if(typeof window!=='undefined' && window.UI) window.UI.onToast(m,t); }`.
   Ends with `function gameInit(){ S = load() || freshState(); ... }` —
   it must NOT self-start (no `setInterval`, no render call).
2. `<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js">` —
   the r128 **global build** (`THREE.*`). It works from `file://` with no
   import map / module server; newer ESM-only builds do not.
3. `<script>` — calls `gameInit()`, sets `window.UI = {onToast, onBattleReport, ...}`,
   builds the scene, renders DOM overlay UI, runs `requestAnimationFrame` +
   the `setInterval(tick, TICK_MS)` game loop.

**Port verbatim**: SAVE_KEY, `migrateSave`, every balance number, quest/event
tables. A 3D rebuild must not rebalance the game — the validated numbers are
the spec.

3D patterns that earn their keep in this genre (full skeleton in
`references/threejs-upgrade.md`): procedural low-poly buildings from
Box/Cylinder/Cone combos with `flatShading:true`; one `villageGroup`
(floating island) + one `worldGroup` (noise terrain + water) toggled by tab;
manual orbit camera (theta/phi/radius lerped to targets — no OrbitControls
import needed); raycaster hover/click selecting buildings via
`userData.btype` walked up the parent chain; day/night cycle driven by
`S.tick`; cheap particles (smithy smoke, fireflies at night); level-scaled
building models (`scaleFor(lvl)`) so upgrades are visible in 3D.

**Verifying the 3D actually renders** (the canvas is invisible to AX-tree
captures and to screenshots of a host app's small preview pane): infer it —
(a) CDN URL returns HTTP 200; (b) the game loop is provably running (an
in-game event/toast/raid notification appears over time, resources change);
(c) the preview console stays clean. Because `new THREE.Scene()` executes
BEFORE the `setInterval` in the same script, a running loop + clean console
means three.js initialized — if the CDN had failed, the whole script would
have halted at line one of the 3D section. State this chain instead of
chasing an un-capturable canvas.

## Physics-based PvP sport games (volleyball-style)

The idle/tycoon/sim material above is the genre the skill was built on, but the same
single-file + game-logic/UI split + three.js upgrade + recursive-loop protocol applies
to real-time physics PvP sport games (Pikachu Volleyball-style: two players, a net, a
ball, first-to-N). Notable differences and the one recurring bug class:

- **The serve-trajectory bug (the headline hazard for ball games).** A serve must cross
  the net AND land on the opponent's side. Verify with projectile math before shipping:
  at the net, `y_net = y0 + vy*(dist_x/vx) - 0.5*g*(dist_x/vx)^2`. If `y_net < net_height`
  the ball fails to clear; if it hits the floor before reaching the net, it lands on the
  SERVER's own side = instant point loss to the server. First build had server at x=±6,
  vx=6, vy=4.2, g=20 → ball hit the floor at t≈0.59s on the server's own side, so every
  serve lost a point (the rally could never even start). Fix: raise serve vy to ~9.5 (and
  vx to ~8) so it clears a 2.2-high net. The balance reviewer caught this by arc-tracing;
  add a playtest assertion "serve clears the net" to lock it down.
- **Balance ties to geometry, not abstract numbers.** Net height must be judged against
  player height (body+head ≈1.55 here — a 2.7 net made every non-jump hit die on the net,
  so only jump spikes worked). Ground-hit apex must exceed net height at the crossing
  point. Tune these three together: net height, ground-hit vy, jump multiplier.
- **Auto-hit vs manual control.** Faithful classic volleyball is auto-hit (ball is hit the
  instant it's in reach) — authentic but it removes player agency. Good middle ground:
  keep auto-hit as the casual fallback AND add a manual spike key (J/L) that overrides with
  more power when timed. `tryHit(pi, manual)` gates the two power tiers. This preserves the
  classic feel while giving skilled players a timed-power option. (Note: an issue where the
  playtest's own variable names were swapped — captured the manual spike into an `autoVx`
  name — was a test-authoring bug, not a game bug; put the assertion beside the capture.)
- The three-reviewer loop (balance/code-quality/UX) maps cleanly onto sports games. The
  balance reviewer caught the serve bug by arc-tracing; the code reviewer caught the
  scoring-poll, AI-deep-ball, char-rotation, and migration issues; the UX reviewer caught
  the 2P key collision and camera/feedback gaps. Do-not-re-report lists and the per-loop
  playtest assertion file work unchanged.
- **Deeper bug classes for this genre** (per-player hitCd in 2P, missing side-ownership
  check, momentum-compounding that ends rallies, net-band collision + micro-bounce clamp,
  score-on-floor-not-bounce, and the headless static-player caveat) are collected in
  `references/real-time-physics-games.md`.

## Multi-pass improvement roadmap

When the user says "keep improving / keep patching," organize the work into
versioned passes rather than one giant edit, each independently verified:
**v1.1 stabilization** (save versioning, autosave interval, corrupted-save
recovery) → **v1.2 balance** (offline-progress rate, event harshness, starting
stats, cost tuning) → **v1.3 content** (new events/achievements, building
effects, specialization systems) → **QoL** (keyboard shortcuts, mobile layout,
UI hints). Run the verify step after EACH pass so a regression is localized to
one small batch.

## AI-facing dev-notes companion file

For games iterated on across many agent sessions, the user often wants a plain
`개발노트.txt` / `DEVNOTES.txt` sitting BESIDE the `.html` so the next agent can
pick up with zero re-explanation. Keep it current on every pass. Sections that
prove their worth: file layout; state-object field list; key-function index;
data tables (resources/systems/buildings/techs/events/achievements with counts);
a dated **change log** of every patch (grouped by version); known remaining
issues; a prioritized future-patch roadmap; and an "agent rules" block (don't
rename the save key, add new fields to `migrateSave`, single-file contract,
how to test). Treat updating this file as part of finishing a pass, not optional.

## Pitfalls

- Never rename the `localStorage` save key — it orphans every existing player.
- A field added to `freshState()` but NOT to `migrateSave()` is a latent crash
  for every returning player. Always edit both together.
- Migration must cover **array elements**, not just top-level `S` fields. When
  you add a `type` or `lab` field to colony objects, old saves have colonies
  without it — loop `s.colonies.forEach(c => { if(!c.type) c.type='mining'; ... })`
  inside `migrateSave`. Same for routes, quests, any sub-object array.
- `TECHS`/config arrays that get items `.push()`-ed at runtime have a
  `.length` that differs from the literal — don't hardcode counts.
- When you change a BASE VALUE (e.g. cargo 50→5000, upgrade increment +25→+2000),
  `if(s.field===undefined)s.field=NEW` does NOT help — old saves HAVE the field,
  just at the old value. You must RECALCULATE from the level/count:
  `s.ship.cargo = NEW_BASE + (s.ship.lv.cargo||0) * NEW_INCREMENT`.
  Otherwise returning players keep the old tiny value forever.
- **Wrong-state-object reads in NPC/AI combat.** When the player's army lives
  in `S.units` but the map stores per-village garrisons in `village.units`,
  AI-attack code easily defends with the MAP object (empty for the player) —
  raids kill nobody and the game feels broken. Any combat code touching the
  player must read the SAME object the training UI writes to.
- **Tutorial/gate condition already satisfied by the fresh state.** A stepwise
  onboarding whose first check is `s.buildings.headquarters>=1` auto-passes the
  instant the game starts (HQ begins at Lv1), silently skipping the first hint.
  Every tutorial/gate `check()` must test a state the player can only reach by
  ACTING — use the NEXT threshold (`>=2`), not the starting value. Re-run a
  fresh-state dry pass after adding any gated step.
- **Flat multipliers on trade/exchange offers create unbounded arbitrage.** A
  merchant offering `getAmt = giveAmt * 1.6` ignores per-resource value —
  giving cheap resources for expensive ones (wood→iron at value 1.0 vs 2.2)
  yields a ~3.5× value gain every time the event rolls, and the event recurs.
  Price exchanges by value ratio with a fixed premium:
  `getAmt = round(giveAmt * VALUE[from]/VALUE[to] * 1.2)`.
- **Escape every save-derived string that goes into innerHTML.** Village names,
  log text, and report titles round-trip through localStorage; a hand-edited
  save can inject `<img onerror=…>` that executes on next load. Keep one
  `esc(s)` helper (replaces `&<>"'`) in the UI script and apply it at every
  interpolation of persisted text. (Keep it OUT of the pure game-logic script —
  the headless harness runs that standalone.)
- **Migration must CLAMP numeric fields, not just default missing ones.**
  `if(s.field===undefined)s.field=X` leaves a hand-corrupted save with
  `era:99` or `questIdx:-5` to break lookups later. In `migrateSave`, clamp:
  `s.era = Math.max(0, Math.min(ERAS.length-1, parseInt(s.era)||0))`.
- Keep it a single HTML file unless the user asks otherwise; that's the contract.

## Applying a multi-agent review batch (recursive-improvement rounds)

This user runs cross-validated improvement loops: N parallel read-only reviewer
subagents → cross-check their findings → apply the agreed fixes → verify with
the headless harness. Protocol rules (user-defined, stored in memory):

- Reviewers are READ-ONLY — they may run the playtest and read code, never edit.
- **Do not start a round unprompted.** The trigger is the user saying
  "재귀개선 시작해" → I ask "몇 번 루프 돌까?" → they answer a count → run that
  many rounds. **Variant:** a single utterance that already contains the count
  (e.g. "재귀개선 3루프 실행해") compresses the whole protocol — start
  immediately with that count, no clarifying question. Apply a batch's results
  only when the user explicitly approves ("이번까지는 적용해") or pre-approved
  the entire multi-loop run; otherwise report and hold.
- **If a reviewer times out (status=timeout, no summary), read its live
  transcript** (`cache/delegation/live/<deleg_id>/task-N.log`) before
  re-dispatching. Reviewers often finish their analysis — even empirical
  verification via a VM test script — and die only while writing the summary.
  One timed-out code-quality reviewer's log yielded three empirically-confirmed
  bugs that were applied directly. Salvage findings from the log, cross-check
  each against the source yourself, then treat them as a completed report. If
  nothing is salvageable, re-dispatch with a time budget: explicit deadline
  ("finish within 10 minutes"), one-pass read strategy ("read the file once in
  ~400-line chunks, then write the report"), fewer items (4–6), and a
  do-not-re-report list of everything already fixed.
- Cross-validate before applying: merge the reviewers' lists, drop duplicates,
  and confirm each fix's target line actually exists / still matches (reports
  may cite line numbers from an older revision or be truncated — re-read the
  source rather than trusting the quoted snippet).
- **Reject proposals that contradict earlier deliberate decisions.** Reviewers
  don't know the decision history. Real case: a code reviewer proposed capping
  quest rewards to the storage cap — rejected, because uncapped rewards were a
  deliberate earlier fix. Keep a mental list of "approved intent" changes and
  screen every new proposal against it; report rejections explicitly in the
  loop summary.
- Apply in dependency order: pure balance numbers first, then code bugs, then
  UI/UX. Run the verify step after the batch, not per-patch.
- **Loop N+1 dispatch prompts must carry the FULL do-not-re-report list from
  ALL previous loops** (each loop's applied fixes appended), plus "read the
  file once in 2–3 chunked reads", "finish within 10 minutes", and "4–6
  items". That combination eliminated the timeout + duplicate-report problems.
- **Per-loop empirical verification script**: after applying, write a throwaway
  `verify_loopN.js` that reuses the playtest VM harness (same DOM stubs,
  `vm.runInContext(gameJs)`, then `gameInit()`) and asserts each individual
  fix with a pass/fail checklist; run it, then delete it. Template + pitfalls
  in `references/recursive-improvement-loops.md`. Two traps that cost real
  time: (a) test scripts calling the wrong function/field names — grep the
  REAL signatures first (`processArmies` not `processArmyOut`; events branch
  on `ev.id`, not `ev.type`); a failing assertion is often a test bug, so
  print actual state before "fixing" the game; (b) game-logic must never call
  UI-script helpers like `fmt()` — the harness runs game-logic standalone, so
  use inline `Math.round(n).toLocaleString()` or a logic-local helper.
- **The playtest must verify the game loop CONTINUES, not just one-shot
  transitions.** A physics/sport game's playtest can pass every single-shot
  assertion (serve clears net, ball landing scores, hits go toward opponent)
  while the game is fundamentally broken — because the state machine can freeze
  after a transition. Real case: every assertion passed (25/25) but the game
  froze after the first point: `scorePoint()` set `rally=false` and never called
  `serve()`, so `stepWorld`'s `if(!S.rally) return` exited forever. The test
  scored a point and stopped; it never checked that a NEW serve happened. ✓ Add
  a **full-loop test**: force a point, then step the world N frames and assert
  `rally` becomes true again (or the next serve fires). This is the single most
  valuable playtest addition for any game with a point→serve→rally round trip.
  Related: re-verify that a long-running headless sim (static opponent) doesn't
  turn a "server isn't an ace" assertion into a false failure — when the game
  auto-continues, a static player will always lose, so test the serve's crossing
  geometry, not the win/loss of a static side.
- **Reviewer summaries get truncated in the inline delegation result.** The
  consolidated `delegate_task` result truncates the middle of each reviewer's
  summary (`…(+N chars)`). The FULL text is in the `subagent-summary-<task>-<ts>.txt`
  files under `cache/delegation/`. When a reviewer's summary file is missing (only
  some tasks write one), extract the full `summary:` text from the live transcript
  `cache/delegation/live/<deleg_id>/task-N.log` with a regex on the `final ... summary:`
  line — the log's stored line is the complete summary even though `read_file`/`tail`
  display it truncated. Never apply a fix from a truncated proposal; read the full
  summary (and its numbers) first.

**Playtest triage after a balance change.** A single run flagging one warning
(e.g. "loss rate >70%") is usually RNG variance, not a regression — re-run
2–3× and look at the distribution. If the warning disappears on re-run and the
rest pass clean, treat it as an outlier and note it, don't chase it. Only
revert/tune if the warning reproduces across multiple runs.

## In-game feedback system pattern

When the user wants to record feedback/bug reports/ideas for future patches,
add a feedback card to the game's status/settings tab:

- **Storage**: separate localStorage key (e.g. `gamename_feedback`), NOT inside
  the game save — survives save resets and version migrations.
- **Format**: `[{date, day, text}, ...]` — auto-records game day + real timestamp.
- **UI**: textarea + 3 buttons (전송/Save, 보기/View list, 내보내기/Export .txt).
- **Export**: generate a plain-text `피드백메모장.txt` via Blob download — the user
  hands this file to the AI agent at the next patch session.
- **AI workflow**: at the start of a patch session, ask for or read the feedback
  file and address items in priority order.

## Detailed-info UI pattern for management games

When the user says "I want more detailed info about X" (colony, building, etc.),
expand the card from a one-line summary to a structured breakdown:

- **Production**: amount + source breakdown (building level × multiplier × bonus)
- **Status indicators**: ✅/⚠️ with color coding (green=sufficient, red=deficit)
- **Growth conditions**: what triggers growth/decline, with current state
- **Cost labels on buttons**: always show the cost in the button text (e.g. "⛏️ 광산+ (500cr)")
- **Confirmation dialogs**: include the TYPE/SPECIALIZATION info so the user
  knows what they're getting before committing

## Companion docs

- `references/save-migration.md` — the full `migrateSave` pattern, a real
  crash-chain (load → render → undefined field), and a synthetic-old-save test.
- `references/event-delegation.md` — the innerHTML-vs-onclick bug, the delegation
  pattern, migration checklist, and why debouncing rendering isn't the fix.
- `references/input-preservation.md` — saveInputs/restoreInputs + the
  needsFullRender sp

…(truncated)
