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:
- Missing save field → render cascade crash. See above. The headline bug.
- 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.
- 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.
- 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.
- 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.
- 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;.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- Reproduce in a real browser first (open the file, click the reported tab,
read the console) — confirm the symptom before touching code.
- 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.
- Back up the file (
cp game.html game.html.bak) before editing.
- 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.
- 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.
- 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:
<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).
<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.
<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> 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)
1---2name: browser-game-development3description: 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.4license: MIT5---67# Standalone HTML/JS Browser Game Development89Use when building or fixing a self-contained browser game — a single `.html`10file with inline CSS+JS, no server, state persisted to `localStorage`. Typical11for idle/tycoon/colony/sim games generated with a coding agent and iterated on12over many sessions. The defining hazard of this genre is **save-format drift**:13the game ships a new version, but the player's `localStorage` still holds a save14from an older version that is missing fields the new code reads.1516## The #1 bug class: save-format forward-compatibility1718When you add ANY new field to the game state object, old saves do not have it.19The first render/tick function that reads `S.newField.something` throws a20`TypeError` on `undefined`, and because these games render by rebuilding a whole21tab's innerHTML in one function, **one missing field silently kills an entire22tab or halts the whole game loop** — the user sees a blank tab and reports23"the X tab doesn't open."2425The fix is a single migration function that back-fills every field with a safe26default, called from BOTH the load-from-localStorage path AND the27import-from-file path. Full pattern + a real crash-chain walkthrough in28`references/save-migration.md`. Rules:2930- One `migrateSave(s)` that sets a default for EVERY field the state can have31 (scalars, arrays, nested objects, per-resource maps, per-system price maps).32- Call it in `load()` and in the save-import handler — never trust raw parsed33 JSON to have all fields.34- When you add a state field, add its default to `migrateSave` in the SAME edit.35 Forgetting this is the bug. Make it a checklist item.36- Optionally stamp a `S.ver` and branch migration by version for big changes.3738## Recurring bug catalog (check all of these on any fix pass)3940Confirmed in real games; each has bitten a shipped build:41421. **Missing save field → render cascade crash.** See above. The headline bug.432. **innerHTML re-render destroys per-element onclick handlers.** The game loop44 calls `renderStation()` / `renderShip()` etc. which rebuild the entire view45 via `innerHTML = ...`, then re-bind `.onclick` on each button. During active46 gameplay (speed > 0), the loop re-renders every frame or on every auto-sell47 tick, so the user's click lands on a button whose handler was just destroyed48 and not yet re-attached. **Symptom**: "buttons work when paused but not at49 1x–3x speed." **Fix**: replace per-element `.onclick` with **event delegation**50 — one `addEventListener('click', ...)` on the parent container (`#view-station`,51 `#view-ship`, etc.) using `e.target.closest('[data-action]')`. Delegated52 listeners survive any innerHTML replacement. Apply to ALL interactive views.53 Also delegate `change` events for file inputs (`importSave`). Full pattern in54 `references/event-delegation.md`.553. **Fractional resource accumulation.** Production like `count * 1.5` yields56 fractional resources that display as `5676.5` and break `cargoUsed` sums.57 Wrap production in `Math.round()`; floor when summing for capacity checks.584. **Offline/idle progress ignores caps AND queues.** "Welcome back, +N days of59 production" dumps resources past the cargo/storage cap — clamp each added60 amount to remaining capacity. Separately, players expect build/train queues61 and army travel to fast-forward too: complete any queue items whose62 `startTick + duration` falls inside the offline window, and mention63 completions in the welcome-back message.64 ⚠️ **Do NOT shift pending army/raid deadlines forward by the elapsed time.**65 Pushing `arriveTick`/`returnTick` (and `pendingAttacks`) forward by elapsed66 keeps their offset from the game clock identical, so combat/conquest/tribute67 that should have resolved *during the absence* never resolves — it's silently68 delayed forever (and the player returns expecting a finished battle). Instead,69 advance the game clock and let the deadlines resolve naturally on the next70 tick(s); add a note to the welcome message like "N arrivals resolving now".71 Confirmed as a real shipped bug and fixed in a cross-validated review loop.725. **Unclosed container div in a render function.** innerHTML builders that73 forget a closing `</div>` break the layout of everything after that card.74 Count your opens/closes when a panel looks wrong.756. **Null-state handlers before init.** Buttons wired at load time76 (`el.onclick = () => S.x = ...`) crash if clicked before the game starts77 (`S === null`). Guard with `if (!S) return;`.787. **Game loop halts on undefined speed/flag.** `if (S.speed > 0)` is `false`79 when `speed` is `undefined`, so an old save permanently freezes the sim.80 Migration defaulting `speed:1` fixes it; also re-sync UI toggle buttons to81 the loaded value on start.828. **Resource cap too small → "can't do anything" frustration.** If the cargo/83 storage cap fills in seconds without auto-sell, the player is locked out of84 all other activities. Fix: set initial cap high enough for minutes of idle85 play (e.g. 5,000+), and make per-upgrade increments large (+2,000 not +25).86 Recalculate in migrateSave from level: `s.cargo = BASE + level * INCREMENT`.879. **Re-render wipes user-typed input values every tick.** Cousin of bug #2:88 the loop rebuilds a view's innerHTML each tick, so a number input the user89 is typing into (train count, army size) resets to its default value 1–2 s90 after they type — the user literally cannot set it. Event delegation (#2)91 fixes clicks but NOT input contents; the DOM node holding the typed value92 is destroyed. Two-part fix: (a) `saveInputs()`/`restoreInputs()` around93 every innerHTML rebuild of an input-bearing view — snapshot id → value +94 focus + caret, re-apply after; (b) split rendering into a cheap per-tick95 `updateDynamic()` (topbar numbers, progress bars, timers only) and a full96 `renderAll()` gated by a `needsFullRender` flag set only on structural97 changes (queue completes, army returns, combat). Full code in98 `references/input-preservation.md`.9910. **three.js GPU leak on scene rebuild (3D builds).** Every rebuild path that100 removes and recreates objects — building models swapped on level-up, world101 map markers rebuilt, warning rings regenerated, army-mesh pool resized,102 particle resets — must dispose GPU resources BEFORE `group.remove()`,103 otherwise every rebuild leaks buffers and a long idle session (hours at104 high speed) grows memory unbounded. Centralize one helper and call it at105 EVERY rebuild site:106 `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();});} }); }`107 Audit checklist: building swap, marker rebuild, ring regen, mesh-pool108 shrink (`while(pool.length>n){ disposeObject(pool.pop()) }`), smoke/particle reset.10911. **Capacity checks ignore queued reservations.** Validating an order against110 a cap (population, housing) by reading only *produced* units lets rapid111 repeated orders each pass individually and overshoot the cap. Count the112 queue too: `getPopUsed() + getQueuedPop() + n*pop > cap` where113 `getQueuedPop()` sums `q.count * pop` over the pending train queue. Same114 principle for any cap that queue items will eventually consume.11512. **Stale-target race at resolution time.** Any action that resolves after a116 delay (army arrival, queued order) must re-check the target's CURRENT117 ownership/state at resolution, not trust what was true at dispatch. Real118 case: two armies targeted the same village in the same tick — army A119 conquered it (`type` flipped to `player`), then army B's arrival handler120 looked the target up by coordinates only and attacked the PLAYER'S OWN121 territory, looting themselves. Fix: in the arrival branch,122 `if(target.type==='player'){ return home (log it); } else resolveCombat()`.123 Same rule for any queue: validate the target is still actionable when the124 timer fires, and pick a graceful fallback (turn back, refund, cancel).12513. **Background-tab throttling erases game time AND blocks offline126 compensation.** Browsers throttle background tabs' `setInterval` to ~1/min;127 a fixed-step tick advances one step per callback, so 10 background minutes128 ≈ a handful of ticks. Worse, each throttled callback refreshes129 `S.lastTick=Date.now()`, so `applyOfflineProgress()` sees a small gap on130 return and compensates nothing — lost time is permanent. Fix inside the131 interval, before `tick()`: `gap=(Date.now()-S.lastTick)/1000; if(gap>=5 &&132 S.speed>0) applyOfflineProgress();` (or a delta-based tick). Optionally133 also hook `visibilitychange`.13414. **Silent localStorage save failure.** `try{setItem}catch(e){}` swallows135 quota-exceeded / private-mode errors; the player plays hours, closes the136 tab, loses everything. Fix: catch → `console.warn` + ONE-time warning137 toast guarded by a `saveWarned` flag.13815. **Trades destroy the overflow above the cap.** Pattern139 `res[from]-=amount; res[to]=min(cap, +got)` deducts full payment then140 clamps the receipt — the difference vanishes when the destination is near141 cap. Validate `res[to]+got <= cap` BEFORE deducting; refuse with a clear142 message instead of half-committing. Same audit for merchant accepts and143 quest rewards.14416. **Corrupt-but-parseable saves black-screen the game forever.** Migration145 fills missing fields but cannot reconstruct structural invariants — e.g. a146 save whose map has no player home village crashes every home-lookup147 forever. `load()` must validate invariants (resources present, map148 non-empty, home exists) and return `null` → fresh state, not boot broken.14917. **Ticker/log dedupe by timestamp drops same-instant entries.**150 `if(entry.time<=lastShown)return` skips distinct entries created in the151 same millisecond (batched events within one tick). Dedupe on a monotonic152 sequence instead: `S.logSeq++` in `addLog`, persisted, defaulted in153 migrate.15418. **Delayed/queued actions must be reversible — and the reversal must be155 physically honest.** Two recurring player-frustration gaps in strategy games:156 (a) **Queue cancellation** — build/train queues deduct resources up front but157 offer no cancel. Add a per-item ✕ that removes the slot, refunds the FULL158 cost (clamped to the storage cap), and then **re-chains the remaining items'159 `startTick`** (`prevEnd = startTick+duration` walks down the list) so later160 items don't keep a stale gap. (b) **Army recall** — an army in the outbound161 phase should be recallable; the return deadline must be162 `returnTick = now + (now - startTick)` (distance already travelled), NOT163 `now + (arriveTick - startTick)` (full round-trip) — the latter charges the164 player double and feels wrong. Got this wrong once (the verify assertion165 encoded the round-trip expectation); the travelled-distance version is the166 honest one.16719. **Autosave cadence keyed to game ticks varies with speed.**168 `if(S.tick % 30 === 0) save()` writes ~100KB JSON every 3 real seconds at169 speed=10 but only every 30s at speed=1. Key saving to wall time instead:170 `if(Date.now()-S.lastSave>=30000) save()` (update `S.lastSave` in `save()`).171 Also add `window.addEventListener('pagehide', function(){ try{save();}catch(e){} })`172 — without it, closing the tab silently loses up to a full autosave interval.17320. **Shared modal overlay + timer expiry = unrelated modal gets nuked.**174 Single-file games typically have ONE modal container; if a background timer175 (merchant offer expiring, event firing) calls the generic close, it also176 kills whatever the player deliberately opened (a battle report, a trade177 screen). Fix: the expiry path must be SCOPED — close only if the modal178 currently showing is the one that owns the timer (check a marker string in179 the modal innerHTML or track an `openModalId`), otherwise leave it open180 and surface the expiry as a toast/log. Game-logic side: don't call a181 generic `uiModalClose()` from a timer — emit a specific bridge event182 (`uiMerchantExpired()`) and let the UI decide.18321. **Batch queues hold large orders hostage; stream them instead.** A train184 order of N units that completes only when the FULL `duration = unitTime*N`185 elapses means a big order delivers nothing for minutes while raids keep186 killing troops ("병력이 안 모이고 계속 줄어" — net-loss death spiral).187 Users expect streaming production: 1 unit finishes every `unitTime`.188 Pattern: queue item gets `unitTime`, `delivered` (delivered so far)189 alongside `count`/`startTick`/`duration`. Every tick:190 `totalDone = clamp(floor((tick - startTick)/unitTime), 0, count)`;191 deliver `totalDone - delivered` fresh units; shift the item only when192 `totalDone >= count`. Four places must ALL honor streaming: (a) the live193 tick loop, (b) offline catch-up (deliver everything that finished within194 the offline window, count them into the welcome message), (c) cancellation195 refund (`count - delivered` remaining units only; already-delivered units196 are spent), (d) queued-cap accounting (`getQueuedPop()` must sum197 `(count-delivered)*pop`, not `count*pop`). UI shows a live198 `delivered/count` counter updated from the bar-update pass.19922. **Troop economy: train rate must outpace raid losses.** When players200 report "troops never accumulate," it's an arithmetic problem, not a bug:201 units/min trained < units/min killed by raids. Levers, in order: cut unit202 train time (`pop*2 + ironCost/60` scale, floor 2s), push the first forced203 raid out (~tick 480–600 instead of 240–320), lengthen the AI attack204 period (30→45 ticks), and soften per-conquest aggro (0.08→0.05). Then205 re-run the balance sim — the "loss rate >70%" warning is the regression206 signal for this class. Full checklist in `references/feature-expansion.md`.207208## Workflow for a "fix my game" request2092101. Reproduce in a real browser first (open the file, click the reported tab,211 read the console) — confirm the symptom before touching code.2122. Read the FULL source. These are one file; read all of it. Trace the reported213 symptom to the exact function and the exact field access that throws.214 ⚠️ When planning a batch of improvements, **verify each one against the215 actual code BEFORE writing patch tuples**. A feature the user mentions may216 already be implemented (e.g. auto-mining was already in `loop()` but the217 user didn't know the tech name). Grep for the feature's key identifiers218 first; skip patches for things that already exist.2193. Back up the file (`cp game.html game.html.bak`) before editing.2204. Apply targeted patches (string-replace), not a rewrite — preserve the user's221 save key and overall structure. For a multi-patch pass, drive it from a222 script with a list of `(old, new, description)` tuples: apply each, report223 ✅/❌ per patch, and **refuse to write the file if ANY tuple fails to match**224 — a partial write leaves the game half-migrated and harder to diagnose than225 a clean abort. Fix the missed tuple's exact string (re-read the real file;226 an earlier patch in the same batch may have shifted the text) and re-run.227 ⚠️ **Stale-file trap**: when you refuse to write, the successful patches in228 that batch are LOST — they only existed in the in-memory string. Your retry229 batch applies to the ORIGINAL on-disk file. You must re-apply ALL patches230 (the previously-successful ones AND the newly-fixed ones) in the retry run.231 This bit us twice in one session: batch 1 had 11/14 pass → not written;232 batch 2 fixed the 3 failures → written; but the 11 from batch 1 were gone.233 Solution: keep the full tuple list and re-run it whole after fixing failures.2345. Verify: syntax-check the script, then unit-test the pure logic functions235 (especially `migrateSave`) against a synthetic OLD save missing the new236 fields. See the `headless-html-testing` skill — for an isolated pure function237 you can extract just it with `new Function(...)` and test directly, no full238 DOM stub needed. On Windows git-bash, `node --check /tmp/x.js` and `$TEMP`239 paths don't resolve (MSYS translation) — extract to a relative `./_check.js`240 in the working directory and delete it afterwards.2416. Deliver a short bug table (what broke / why / fix) so the user sees value.242243## Multi-base expansion ("let me build/train/attack from captured villages")244245When the user wants conquered/captured locations to become real playable bases246(outposts, colonies, second cities), the single-home assumption is baked into247a dozen call sites. A safe expansion pattern, proven end-to-end:248249- **Lazy per-village structure.** Give each player-owned non-home village an250 `outpost` object `{buildings:{...}, units:{...}, buildQueue:[], trainQueue:[]}`251 created on demand by `getOutpost(v)` (never trust old saves). `migrateSave`252 must create it for every existing captured village when bumping the save253 version.254- **Accessor indirection instead of `S.units`/`S.buildings` reads.** Add255 `getVillageUnits(v)` / `getVillageBuildings(v)` returning the home arrays or256 the outpost arrays; rewrite combat, training, population, and queue code to257 go through them. Grep every literal `S.units` and `S.buildings` use and258 decide per-site whether it's home-only or village-generic.259- **Origin-aware actions.** `sendArmy(..., originX, originY)` — units leave260 FROM and RETURN TO the village they marched out of (not always home). The261 arrival/return handler must look up the origin village and handle it having262 changed ownership meanwhile (see bug #12). UI: an origin-selector row263 (home + outposts with the prerequisite building) above the dispatch form.264- **Outpost-scoped queues.** Build/train queue processors, offline catch-up,265 cancellation, and capacity checks all need the outpost branch — these are266 the call sites everyone forgets; walk the full list in267 `references/feature-expansion.md`.268- **Caps go global, prerequisites go local.** Population cap sums home farm +269 outpost farms (a real reward for expansion), but unit unlock requirements270 (`req:{barracks:3}`) check the ORIGIN village's buildings, not home's —271 otherwise outposts train anything from tick one.272- **Threat model updates.** AI raids should target the nearest player village273 (home or outpost), not always home; a raid whose origin village was captured274 meanwhile cancels gracefully.275- **Verify with a scripted capture.** In the verify script, force a conquest276 (`v.type='player'` + `v.outpost={...}`), then assert build → train → stream277 → dispatch → return-to-origin all work on the outpost AND home still works278 (backward compat). Include a synthetic old-version save in the migration279 checks.280281## When the user says "it's not fun"282283"Not fun" in a strategy/sim game almost always means **no meaningful choices**,284not "not enough content." Diagnose by checking for these missing elements (in285impact order):286287| Missing element | Why it matters |288|-----------------|---------------|289| Random events | Every session should feel different; "something happens" every 60-90s |290| Quests/goals | Player needs direction + dopamine from completion rewards |291| Tactical combat | Attack vs defense division is boring; formations, rounds, and logs add agency |292| Tech/era progression | "What unlocks next" is the core progression hook |293| AI threat | If the AI never attacks, there's no tension; raids must be telegraphed but real |294| Resource trading | Surplus/deficit creates economic puzzles |295296Fix in this order — each one independently testable via the headless balance297sim (see `headless-html-testing` skill, "Balance simulation harness" section).298299## AI threat tuning (strategy games)300301⚠️ **These knobs are bidirectional.** The defaults below assume the common302failure mode — AI that never attacks. If the complaint is the OPPOSITE303("raids more frequent than training, troops never accumulate"), reverse the304direction: push the first raid LATER, lengthen the period, lower aggro.305Bug-catalog #22 and `references/feature-expansion.md` §2 carry the306relaxation-side values.307308AI that never attacks makes the game a spreadsheet. Tuning knobs:309310- **Raid period**: 45-60 ticks (not 90+). Shorter = more pressure.311- **Base aggro**: 0.25-0.55 random range (not 0.15-0.40). Higher = more attacks.312- **Min force threshold**: 3-4 units (not 6+). Early-game AI villages have few313 units; a high threshold means zero raids for the first 20 minutes.314- **First-raid guarantee**: Force one attack around tick 180-240 if no raids315 have occurred yet. Prevents the "40 minutes of peace" anti-pattern.316- **Conquest-scaled aggro**: `effectiveAggro = base + conquered * 0.08`.317 Punishes expansion — the more you take, the more they come.318- **Telegraphed attacks**: Show "X is gathering forces, arriving in N seconds"319 before the raid lands. Gives the player time to react (train defenders,320 recall army). This is what makes raids FEEL like threats rather than random321 damage.322- **AI growth**: AI villages should regen units every cycle, slightly faster323 when the player is stronger. Keep regen MODEST — `ceil(level*0.15*grow)` per324 unit type with `grow = playerStrong ? 1.15 : 1.0`. Earlier advice of325 `level*0.3` with `grow 1.3–1.8` was empirically broken: a level-5 village326 then restored ~13+ units/min, faster than a round-trip expedition, so327 conquering distant/high-level villages became a structurally unwinnable war328 of attrition. Regen must be slower than a focused player army can remove.329- **Loot/carry balance**: If loot per raid is trivially small (e.g. 158 per330 resource after a 60-min sim), the player has no economic incentive to fight.331 Target: carry values of 30-100 per unit type, and make the LOOT SUM match332 total carry — set per-resource loot to `carry * 0.25` when there are 4333 resources (NOT 0.5, which inflates total loot to 2× carry and doubles the334 effective carry stat). Caps scaled to target level (e.g. 800 × level).335- **Combat rounds vs conquest**: with a fixed kill ratio per round, N rounds336 leaves `survivor ≈ (1-k)^N` of the garrison. 3 rounds at k≈0.55 leaves ~9%337 alive, so single-battle conquest is nearly impossible even when338 overwhelming. 4 rounds leaves ~2% — enough for decisive victories while339 keeping upsets possible. Tune rounds together with AI regen: if regen is340 fast, even a 95% wipe recovers before the player returns.341342## three.js 3D upgrade ("the UI is too plain")343344Trigger: user says the flat HTML version looks too simple / wants richer345graphics. For this user, three.js is the DEFAULT for all game requests unless346they say otherwise — don't ask, just build in 3D.347348**Architecture — still ONE html file, but split into two scripts** so the349headless playtest harness keeps working unchanged:3503511. `<script id="game-logic">` — ALL game state + logic, ZERO DOM access.352 UI callbacks go through a bridge: `function uiToast(m,t){ if(typeof window!=='undefined' && window.UI) window.UI.onToast(m,t); }`.353 Ends with `function gameInit(){ S = load() || freshState(); ... }` —354 it must NOT self-start (no `setInterval`, no render call).3552. `<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js">` —356 the r128 **global build** (`THREE.*`). It works from `file://` with no357 import map / module server; newer ESM-only builds do not.3583. `<script>` — calls `gameInit()`, sets `window.UI = {onToast, onBattleReport, ...}`,359 builds the scene, renders DOM overlay UI, runs `requestAnimationFrame` +360 the `setInterval(tick, TICK_MS)` game loop.361362**Port verbatim**: SAVE_KEY, `migrateSave`, every balance number, quest/event363tables. A 3D rebuild must not rebalance the game — the validated numbers are364the spec.3653663D patterns that earn their keep in this genre (full skeleton in367`references/threejs-upgrade.md`): procedural low-poly buildings from368Box/Cylinder/Cone combos with `flatShading:true`; one `villageGroup`369(floating island) + one `worldGroup` (noise terrain + water) toggled by tab;370manual orbit camera (theta/phi/radius lerped to targets — no OrbitControls371import needed); raycaster hover/click selecting buildings via372`userData.btype` walked up the parent chain; day/night cycle driven by373`S.tick`; cheap particles (smithy smoke, fireflies at night); level-scaled374building models (`scaleFor(lvl)`) so upgrades are visible in 3D.375376**Verifying the 3D actually renders** (the canvas is invisible to AX-tree377captures and to screenshots of a host app's small preview pane): infer it —378(a) CDN URL returns HTTP 200; (b) the game loop is provably running (an379in-game event/toast/raid notification appears over time, resources change);380(c) the preview console stays clean. Because `new THREE.Scene()` executes381BEFORE the `setInterval` in the same script, a running loop + clean console382means three.js initialized — if the CDN had failed, the whole script would383have halted at line one of the 3D section. State this chain instead of384chasing an un-capturable canvas.385386## Physics-based PvP sport games (volleyball-style)387388The idle/tycoon/sim material above is the genre the skill was built on, but the same389single-file + game-logic/UI split + three.js upgrade + recursive-loop protocol applies390to real-time physics PvP sport games (Pikachu Volleyball-style: two players, a net, a391ball, first-to-N). Notable differences and the one recurring bug class:392393- **The serve-trajectory bug (the headline hazard for ball games).** A serve must cross394 the net AND land on the opponent's side. Verify with projectile math before shipping:395 at the net, `y_net = y0 + vy*(dist_x/vx) - 0.5*g*(dist_x/vx)^2`. If `y_net < net_height`396 the ball fails to clear; if it hits the floor before reaching the net, it lands on the397 SERVER's own side = instant point loss to the server. First build had server at x=±6,398 vx=6, vy=4.2, g=20 → ball hit the floor at t≈0.59s on the server's own side, so every399 serve lost a point (the rally could never even start). Fix: raise serve vy to ~9.5 (and400 vx to ~8) so it clears a 2.2-high net. The balance reviewer caught this by arc-tracing;401 add a playtest assertion "serve clears the net" to lock it down.402- **Balance ties to geometry, not abstract numbers.** Net height must be judged against403 player height (body+head ≈1.55 here — a 2.7 net made every non-jump hit die on the net,404 so only jump spikes worked). Ground-hit apex must exceed net height at the crossing405 point. Tune these three together: net height, ground-hit vy, jump multiplier.406- **Auto-hit vs manual control.** Faithful classic volleyball is auto-hit (ball is hit the407 instant it's in reach) — authentic but it removes player agency. Good middle ground:408 keep auto-hit as the casual fallback AND add a manual spike key (J/L) that overrides with409 more power when timed. `tryHit(pi, manual)` gates the two power tiers. This preserves the410 classic feel while giving skilled players a timed-power option. (Note: an issue where the411 playtest's own variable names were swapped — captured the manual spike into an `autoVx`412 name — was a test-authoring bug, not a game bug; put the assertion beside the capture.)413- The three-reviewer loop (balance/code-quality/UX) maps cleanly onto sports games. The414 balance reviewer caught the serve bug by arc-tracing; the code reviewer caught the415 scoring-poll, AI-deep-ball, char-rotation, and migration issues; the UX reviewer caught416 the 2P key collision and camera/feedback gaps. Do-not-re-report lists and the per-loop417 playtest assertion file work unchanged.418- **Deeper bug classes for this genre** (per-player hitCd in 2P, missing side-ownership419 check, momentum-compounding that ends rallies, net-band collision + micro-bounce clamp,420 score-on-floor-not-bounce, and the headless static-player caveat) are collected in421 `references/real-time-physics-games.md`.422423## Multi-pass improvement roadmap424425When the user says "keep improving / keep patching," organize the work into426versioned passes rather than one giant edit, each independently verified:427**v1.1 stabilization** (save versioning, autosave interval, corrupted-save428recovery) → **v1.2 balance** (offline-progress rate, event harshness, starting429stats, cost tuning) → **v1.3 content** (new events/achievements, building430effects, specialization systems) → **QoL** (keyboard shortcuts, mobile layout,431UI hints). Run the verify step after EACH pass so a regression is localized to432one small batch.433434## AI-facing dev-notes companion file435436For games iterated on across many agent sessions, the user often wants a plain437`개발노트.txt` / `DEVNOTES.txt` sitting BESIDE the `.html` so the next agent can438pick up with zero re-explanation. Keep it current on every pass. Sections that439prove their worth: file layout; state-object field list; key-function index;440data tables (resources/systems/buildings/techs/events/achievements with counts);441a dated **change log** of every patch (grouped by version); known remaining442issues; a prioritized future-patch roadmap; and an "agent rules" block (don't443rename the save key, add new fields to `migrateSave`, single-file contract,444how to test). Treat updating this file as part of finishing a pass, not optional.445446## Pitfalls447448- Never rename the `localStorage` save key — it orphans every existing player.449- A field added to `freshState()` but NOT to `migrateSave()` is a latent crash450 for every returning player. Always edit both together.451- Migration must cover **array elements**, not just top-level `S` fields. When452 you add a `type` or `lab` field to colony objects, old saves have colonies453 without it — loop `s.colonies.forEach(c => { if(!c.type) c.type='mining'; ... })`454 inside `migrateSave`. Same for routes, quests, any sub-object array.455- `TECHS`/config arrays that get items `.push()`-ed at runtime have a456 `.length` that differs from the literal — don't hardcode counts.457- When you change a BASE VALUE (e.g. cargo 50→5000, upgrade increment +25→+2000),458 `if(s.field===undefined)s.field=NEW` does NOT help — old saves HAVE the field,459 just at the old value. You must RECALCULATE from the level/count:460 `s.ship.cargo = NEW_BASE + (s.ship.lv.cargo||0) * NEW_INCREMENT`.461 Otherwise returning players keep the old tiny value forever.462- **Wrong-state-object reads in NPC/AI combat.** When the player's army lives463 in `S.units` but the map stores per-village garrisons in `village.units`,464 AI-attack code easily defends with the MAP object (empty for the player) —465 raids kill nobody and the game feels broken. Any combat code touching the466 player must read the SAME object the training UI writes to.467- **Tutorial/gate condition already satisfied by the fresh state.** A stepwise468 onboarding whose first check is `s.buildings.headquarters>=1` auto-passes the469 instant the game starts (HQ begins at Lv1), silently skipping the first hint.470 Every tutorial/gate `check()` must test a state the player can only reach by471 ACTING — use the NEXT threshold (`>=2`), not the starting value. Re-run a472 fresh-state dry pass after adding any gated step.473- **Flat multipliers on trade/exchange offers create unbounded arbitrage.** A474 merchant offering `getAmt = giveAmt * 1.6` ignores per-resource value —475 giving cheap resources for expensive ones (wood→iron at value 1.0 vs 2.2)476 yields a ~3.5× value gain every time the event rolls, and the event recurs.477 Price exchanges by value ratio with a fixed premium:478 `getAmt = round(giveAmt * VALUE[from]/VALUE[to] * 1.2)`.479- **Escape every save-derived string that goes into innerHTML.** Village names,480 log text, and report titles round-trip through localStorage; a hand-edited481 save can inject `<img onerror=…>` that executes on next load. Keep one482 `esc(s)` helper (replaces `&<>"'`) in the UI script and apply it at every483 interpolation of persisted text. (Keep it OUT of the pure game-logic script —484 the headless harness runs that standalone.)485- **Migration must CLAMP numeric fields, not just default missing ones.**486 `if(s.field===undefined)s.field=X` leaves a hand-corrupted save with487 `era:99` or `questIdx:-5` to break lookups later. In `migrateSave`, clamp:488 `s.era = Math.max(0, Math.min(ERAS.length-1, parseInt(s.era)||0))`.489- Keep it a single HTML file unless the user asks otherwise; that's the contract.490491## Applying a multi-agent review batch (recursive-improvement rounds)492493This user runs cross-validated improvement loops: N parallel read-only reviewer494subagents → cross-check their findings → apply the agreed fixes → verify with495the headless harness. Protocol rules (user-defined, stored in memory):496497- Reviewers are READ-ONLY — they may run the playtest and read code, never edit.498- **Do not start a round unprompted.** The trigger is the user saying499 "재귀개선 시작해" → I ask "몇 번 루프 돌까?" → they answer a count → run that500 many rounds. **Variant:** a single utterance that already contains the count501 (e.g. "재귀개선 3루프 실행해") compresses the whole protocol — start502 immediately with that count, no clarifying question. Apply a batch's results503 only when the user explicitly approves ("이번까지는 적용해") or pre-approved504 the entire multi-loop run; otherwise report and hold.505- **If a reviewer times out (status=timeout, no summary), read its live506 transcript** (`cache/delegation/live/<deleg_id>/task-N.log`) before507 re-dispatching. Reviewers often finish their analysis — even empirical508 verification via a VM test script — and die only while writing the summary.509 One timed-out code-quality reviewer's log yielded three empirically-confirmed510 bugs that were applied directly. Salvage findings from the log, cross-check511 each against the source yourself, then treat them as a completed report. If512 nothing is salvageable, re-dispatch with a time budget: explicit deadline513 ("finish within 10 minutes"), one-pass read strategy ("read the file once in514 ~400-line chunks, then write the report"), fewer items (4–6), and a515 do-not-re-report list of everything already fixed.516- Cross-validate before applying: merge the reviewers' lists, drop duplicates,517 and confirm each fix's target line actually exists / still matches (reports518 may cite line numbers from an older revision or be truncated — re-read the519 source rather than trusting the quoted snippet).520- **Reject proposals that contradict earlier deliberate decisions.** Reviewers521 don't know the decision history. Real case: a code reviewer proposed capping522 quest rewards to the storage cap — rejected, because uncapped rewards were a523 deliberate earlier fix. Keep a mental list of "approved intent" changes and524 screen every new proposal against it; report rejections explicitly in the525 loop summary.526- Apply in dependency order: pure balance numbers first, then code bugs, then527 UI/UX. Run the verify step after the batch, not per-patch.528- **Loop N+1 dispatch prompts must carry the FULL do-not-re-report list from529 ALL previous loops** (each loop's applied fixes appended), plus "read the530 file once in 2–3 chunked reads", "finish within 10 minutes", and "4–6531 items". That combination eliminated the timeout + duplicate-report problems.532- **Per-loop empirical verification script**: after applying, write a throwaway533 `verify_loopN.js` that reuses the playtest VM harness (same DOM stubs,534 `vm.runInContext(gameJs)`, then `gameInit()`) and asserts each individual535 fix with a pass/fail checklist; run it, then delete it. Template + pitfalls536 in `references/recursive-improvement-loops.md`. Two traps that cost real537 time: (a) test scripts calling the wrong function/field names — grep the538 REAL signatures first (`processArmies` not `processArmyOut`; events branch539 on `ev.id`, not `ev.type`); a failing assertion is often a test bug, so540 print actual state before "fixing" the game; (b) game-logic must never call541 UI-script helpers like `fmt()` — the harness runs game-logic standalone, so542 use inline `Math.round(n).toLocaleString()` or a logic-local helper.543- **The playtest must verify the game loop CONTINUES, not just one-shot544 transitions.** A physics/sport game's playtest can pass every single-shot545 assertion (serve clears net, ball landing scores, hits go toward opponent)546 while the game is fundamentally broken — because the state machine can freeze547 after a transition. Real case: every assertion passed (25/25) but the game548 froze after the first point: `scorePoint()` set `rally=false` and never called549 `serve()`, so `stepWorld`'s `if(!S.rally) return` exited forever. The test550 scored a point and stopped; it never checked that a NEW serve happened. ✓ Add551 a **full-loop test**: force a point, then step the world N frames and assert552 `rally` becomes true again (or the next serve fires). This is the single most553 valuable playtest addition for any game with a point→serve→rally round trip.554 Related: re-verify that a long-running headless sim (static opponent) doesn't555 turn a "server isn't an ace" assertion into a false failure — when the game556 auto-continues, a static player will always lose, so test the serve's crossing557 geometry, not the win/loss of a static side.558- **Reviewer summaries get truncated in the inline delegation result.** The559 consolidated `delegate_task` result truncates the middle of each reviewer's560 summary (`…(+N chars)`). The FULL text is in the `subagent-summary-<task>-<ts>.txt`561 files under `cache/delegation/`. When a reviewer's summary file is missing (only562 some tasks write one), extract the full `summary:` text from the live transcript563 `cache/delegation/live/<deleg_id>/task-N.log` with a regex on the `final ... summary:`564 line — the log's stored line is the complete summary even though `read_file`/`tail`565 display it truncated. Never apply a fix from a truncated proposal; read the full566 summary (and its numbers) first.567568**Playtest triage after a balance change.** A single run flagging one warning569(e.g. "loss rate >70%") is usually RNG variance, not a regression — re-run5702–3× and look at the distribution. If the warning disappears on re-run and the571rest pass clean, treat it as an outlier and note it, don't chase it. Only572revert/tune if the warning reproduces across multiple runs.573574## In-game feedback system pattern575576When the user wants to record feedback/bug reports/ideas for future patches,577add a feedback card to the game's status/settings tab:578579- **Storage**: separate localStorage key (e.g. `gamename_feedback`), NOT inside580 the game save — survives save resets and version migrations.581- **Format**: `[{date, day, text}, ...]` — auto-records game day + real timestamp.582- **UI**: textarea + 3 buttons (전송/Save, 보기/View list, 내보내기/Export .txt).583- **Export**: generate a plain-text `피드백메모장.txt` via Blob download — the user584 hands this file to the AI agent at the next patch session.585- **AI workflow**: at the start of a patch session, ask for or read the feedback586 file and address items in priority order.587588## Detailed-info UI pattern for management games589590When the user says "I want more detailed info about X" (colony, building, etc.),591expand the card from a one-line summary to a structured breakdown:592593- **Production**: amount + source breakdown (building level × multiplier × bonus)594- **Status indicators**: ✅/⚠️ with color coding (green=sufficient, red=deficit)595- **Growth conditions**: what triggers growth/decline, with current state596- **Cost labels on buttons**: always show the cost in the button text (e.g. "⛏️ 광산+ (500cr)")597- **Confirmation dialogs**: include the TYPE/SPECIALIZATION info so the user598 knows what they're getting before committing599600## Companion docs601602- `references/save-migration.md` — the full `migrateSave` pattern, a real603 crash-chain (load → render → undefined field), and a synthetic-old-save test.604- `references/event-delegation.md` — the innerHTML-vs-onclick bug, the delegation605 pattern, migration checklist, and why debouncing rendering isn't the fix.606- `references/input-preservation.md` — saveInputs/restoreInputs + the607 needsFullRender sp608609…(truncated)