cron_472db69a142a_20260819_005321 — dmr-build-loop · Aug 19 00:58
dmr-build-loop · Aug 19 00:58
- Session ID:
cron_472db69a142a_20260819_005321 - Source: cron
- Model:
MiniMax-M3 - Started: 2026-08-19T00:53:21
- Ended: 2026-08-19T00:58:06
- Messages (user+assistant): 82
user (2026-08-19T00:53:21)
[IMPORTANT: The user has invoked the “spire-defense-build-protocol” skill, indicating they want you to follow its instructions. The full skill content is loaded below.]
name: spire-defense-build-protocol description: “Spire-Defense build protocol. 119 rules. Rules 99-119 cover: migration-loop discipline (99), named-parameter scope (102), runtime-constant sourcing in descriptions (103), visual clutter (104), linear interp for end-point tuning (105), homing projectile lose-target (106), patch-tool hygiene (107), cap-reached UI (108), class immunity graduated tiers (109), three-site kill-bucket drift (110), per-tier survival model (111), Pure-Fib infinite-game curve (112), wave-5000 game-speed gate for all tiers (113), believe-before-proving consumer grep (114), per-tick killer-blow attribution via contribution map (115), per-tier stun resistance with stochastic roll (116), defer modal render with queueMicrotask to break the BEACON_DEAD listener race (117), count-based gem-market unlock via per-tier gemMarketUpgrade stacking (118), module-load override loops must come AFTER const declarations (119 — TDZ trap). Verify pitfalls in references/verify-script-pitfalls.md.” version: 3.3.0 date: 2026-08-08 author: Hermes license: MIT metadata: hermes: tags: “game-dev, spire-defense, og-style” related_skills: “idle-scaffold, iterative-game-dev-protocol, systematic-debugging”
Rule 104. Visual clutter = count × mass — coordinate three levers (2026-08-06)
When a visual artifact dominates the screen (trails, particles, arcs, ray casts, line segments), the dominant term in the clutter is count × per-artifact mass, not either alone. Cut one lever, the other still tangles the screen. Cut all three together, the visual returns to readable.
The three coordinated levers:
- Turn rate up (or path-smoothness up). Reduces arc radius.
arcRadius = speed / turnRate. For Homing Missiles: 220/4.5 = 49px → cut to 220/8.0 = 28px. The trail reads as a homing path, not a spiral. - Artifact cap down (or per-frame batch size down). Reduces per-missile line segments. Trail 12 → 6 cuts line segments in half. Each trail covers ~100ms of flight (the visually meaningful recent path), not the full 200ms.
- Alpha down (or visual mass down). Trail alpha 0.6 → 0.4 makes trails read as a faint hint rather than a bright streak.
Ship all three together. One lever alone leaves the visual mess. The three combine to cut perceived visual mass ~4-5×.
The diagnostic-before-patch pattern (rule 84 in action): when the user reports “X is too much on screen,” the first instinct is to reduce X’s size. The correct first step is a focused diagnostic that confirms the shape: is the steering correct? (single trajectory) What’s the arc radius? (speed / turnRate) What’s the count × mass product? (trailLength × missileCount). The diagnostic points at the right coordinates to cut. In the Homing Missiles case, the diagnostic was tests/homing-missile-orbit-diagnostic.mjs — a single missile’s full trail + tick log. Result: 52 shrinks, 0 grows — steering was correct, the visual mess was a count × mass problem.
Full writeup: references/lessons-2026-08-06-sapper-homing-tuning.md — rule 104 section + the worked example from the “screen gets so messy” 2026-08-06 Homing Missiles tuning ask.
Mental shorthand: “Visual clutter is (count × mass). Ship three coordinated levers — turn rate, artifact cap, alpha — not just one.”
Rule 105. End-point-fixed tuning wants linear interpolation, not multiplicative decay (2026-08-06)
When OG specifies a curve with two end-points (“X at level 0, Y at level N”, or “cooldown starts at 15s, is 5s at level 30”), the natural assumption is a multiplicative decay (base * reduction^level). The result rarely hits the target end-point cleanly: 15 * 0.9^30 = 0.49s, not 5s. The clean fit is linear interpolation:
const PER_LEVEL = (BASE - TARGET) / N;
function curve(L) {
return Math.max(TARGET, BASE - L * PER_LEVEL);
}
// At L=0: BASE.
// At L=N: TARGET (exact).
// At L>30: TARGET (clamped).
Why multiplicative feels natural but doesn’t fit: with multiplicative decay, the curve approaches 0 asymptotically; the “end-point” is whatever the formula happens to produce at level N, not the value the user wants. The fix is to back-solve the reduction so the curve hits the target: reduction = (target / base)^(1/N). For 15→5 over 30 levels, that’s reduction = 0.926. The formula 15 * 0.926^L is ugly and unintuitive — and any further tuning requires re-deriving the reduction from the new end-points. Linear keeps the end-points exact, the per-level reduction is a clean number, and the formula is BASE - L * PER_LEVEL.
The pattern (worked example, 2026-08-06 Homing Missiles cooldown):
- OG: “cooldown 15s at level 0, 5s at level 30”
- Multiplicative at 0.90:
15 * 0.9^30 = 0.49s→ way past 5s - Multiplicative at 0.926:
15 * 0.926^30 = 5.00s(exact) but the formula is opaque - Linear:
15 - L * 0.333. Bumping the base to 20s auto-adjusts PER_LEVEL to0.5. Bumping the target to 8s auto-adjusts PER_LEVEL to0.4. The formula is the same shape — readable, single-source-of-truth.
The verify pattern: assert the formula at three levels (level 0, level N, level N+1) and the unclamped clamp level (level 2N). The clamp ensures the formula doesn’t go negative past the target. The Homing Missiles tuning verify asserts L=0 → 15s, L=10 → 11.67s, L=20 → 8.33s, L=30 → 5.0s, L=50 → 5.0s (clamped), exercising both the linear portion and the clamp.
Anti-pattern to avoid: the “subtle multiplicative” trap. reduction = 0.93 or reduction = 0.95 looks like a gentle decay but the curve hits zero around level 50-100. The user almost never wants zero at any attainable level — the curve should hit a target end-point and clamp. If the spec doesn’t say what happens past level N, linear with clamp is the default; multiplicative without explicit spec is the bait.
Mental shorthand: “End-point-fixed tuning? Linear interpolation. Multiplicative decay approaches 0 asymptotically — the end-point is whatever the formula happens to produce, not what the user wants.”
Rule 106. Homing projectile lose-target behavior — freeze heading, detonate at safety boundary (2026-08-06)
When a homing projectile’s target dies mid-flight, the default behavior (keep steering toward the dead position) produces ugly spirals — the projectile can never catch a non-moving target offset from its trajectory, so it circles indefinitely. The fix is a transition detection + a deterministic detonation rule:
- First-tick lost-target detection: when the target goes from alive to dead (or null), set
m.lostTarget = true. Don’t detect “no target” alone — many projectiles start with a null target, and those should never trigger. The trigger is the transition (alive → dead). - Freeze the heading: while
lostTarget, skip the heading update, the close-range snap, and the “short step when within 2px of target” logic. The projectile moves forward at full speed in a straight line. - Detonate at a safety boundary: when the projectile reaches the halo radius (or any other game-defined boundary — wall, screen edge, distance threshold), call
_detonate(). The detonation applies AOE effects to any enemy in the radius; no direct-hit credit (no target to credit the kill to).
The visual outcome: a straight line from the mid-flight detonation point to the halo edge, then a burst at the edge. Reads as “the missile kept going and exploded when it hit the safety ring.” Far cleaner than a spiral around a dead enemy.
The transition-detection subtle case: the test m.lostTarget === undefined is wrong. The Missile instance is created fresh, so lostTarget is undefined initially. But after the first tick the target is alive, m.lostTarget = false (set unconditionally when target is alive). When the target dies on tick N+1, the check m.lostTarget === undefined is false — the freeze never fires. The correct check is !m.lostTarget (catches both undefined and false). The verify catches this in tests/homing-missile-tuning-verify.mjs Section 3.
The boundary-detect operator-inversion pitfall (2026-08-06, follow-up): when implementing the “detonate at halo” check, the natural first draft is:
// WRONG: the missile starts at the spire (distance 0) and flies
// OUTWARD. The `<=` check fires on the FIRST tick after the target
// dies — the missile is still ~3.6 px from the spire, which is
// well within halo radius. Detonates on the spire on tick 1.
if (dx * dx + dy * dy <= haloR * haloR) hit = true;
The correct check is “at or past the halo edge” (>=), not “within the halo zone” (<=). The boundary is the destination, not the interior. The verify catches this directly — assert that the detonation distance from the spire is ≈ haloR, not 0:
// Test asserts the missile detonates AT the edge, not on the spire.
assert(
`detonation distance from spire ≈ haloR (${haloR}px ± 15)`,
Math.abs(finalDistFromSpire - haloR) <= 15,
`detonated at ${finalDistFromSpire.toFixed(1)}px from spire, haloR=${haloR}`,
);
The general class: any time a “fire the event when the projectile reaches X” check uses a distance comparison, the operator direction depends on the projectile’s starting position. If the projectile starts at the boundary and flies outward, the right operator is >= (fire when the projectile crosses the boundary from inside to outside). If the projectile starts outside and flies inward, the right operator is <= (fire when the projectile crosses inward). The trap is symmetric — both forms are plausible in isolation. The diagnose-by-verify pattern (assert the detonation distance is at the boundary, not 0) catches both.
The detonation source: the boundary is the spire’s ambient light radius (beacon.getMaxHaloRadius()). The fallback is 180px (matches BeaconPulse.FALLBACK_RADIUS_PX). The detect is this._spireX != null to avoid NaN explosions if the missile was constructed before the spire position was wired up.
The general pattern (any homing projectile): any projectile that aims at a target instance should have:
m.lostTargetflag with transition detection (alive → dead, not just “no target”).- Frozen heading while
lostTarget. - A detonation rule (boundary, distance, or lifetime-based) that fires while
lostTarget. The boundary operator (<=vs>=) depends on the projectile’s starting position relative to the boundary.
Caught by: the 2026-08-06 Homing Missiles tuning thread, twice. First: “missiles are still acting weird and circling enemies that already died. If a missile-target-enemy dies, let the missile keep its current trajectory, but just explode at the halo radius.” Second (the operator-inversion follow-up): “The missiles where the target died are exploding on top of the spire itself. I meant that they should detarget and travel out to the edge of the halo radius and explode out there.” The second correction was the operator-inversion bug — the first ship had <= in the boundary check.
Mental shorthand: “Homing projectile, target dies? Freeze heading, detonate at safety boundary. Keep steering toward the dead position = spiral pattern. And remember: the boundary operator (<= vs >=) depends on whether the projectile starts inside or outside the boundary.”
Rule 107. Multi-commit decompression + patch-tool pitfalls (2026-08-06)
When git reset --soft + git commit --amend produce a duplicated history (the original commit, the redo, and the new commits interleaved), the recovery path is:
- Identify the clean baseline.
git log --oneline -5shows the duplicates. Pick the most recent commit before the duplication starts as the new base. - Revert the working tree to baseline.
git reset --soft <base>thengit reset HEAD(un-stage) thengit checkout -- <files>(revert working tree). At this pointgit statusshould show only the untracked files (the new verify scripts) plus the??markers. - Apply each logical change as a separate, small commit. For each change, use
patchwith a NARROW context (the unique anchor + 1-2 lines of surrounding context) to make the edit. Don’t try to ship one commit with multiple concerns — that’s what produced the original mess. - Verify between commits. Run the verify scripts after each commit. If a verify fails, the next commit isn’t yet ready — fix the regression before committing.
The “broad patch context catches” pitfall (rule 107 applies to patch tool usage): when an old_string includes multiple adjacent lines (e.g. a block of constants between two blank lines), the patch can land on a different context if any of those lines changed since the patch was written. Two failure modes seen in the 2026-08-06 session:
- Accidental deletion of unrelated code. A patch targeting
const COUNT_SOFT_CAP = 5;removed the line above it (SPLASH_SOFT_CAP = 30;) because theold_stringcontext included both. The regression crashed every verify that forced a salvo (the missile’sgetSplash()referencesSPLASH_SOFT_CAP). The fix-ship recipe: every constant removal should be preceded bygrep -n '<NAME>' src/to confirm which references exist. If the constant is referenced (directly or transitively), it stays — even if it looks unused. - Patch lands in wrong place. When the file’s structure shifted between reads (someone else added a comment block, or a previous patch pushed lines down), the
old_stringmay match a different occurrence than intended. The symptom: the diff stat looks reasonable but the change is in the wrong section. The fix: re-read the file immediately before eachpatchcall when the file has changed across multiple edits.
The rule: at the start of every patch-and-commit sequence, re-read the file. At each patch, grep before deleting (constants/code that “looks unused” may be referenced). At end of the sequence, run the full verify suite before pushing. The verify pass is the safety net for accidental-deletion bugs, but the grep is the prevention.
Caught by: the 2026-08-06 Homing Missiles tuning session. The first attempt to ship three changes (cooldown formula, uncap, detarget) in batch produced 3 commits with one bloated diff (134 lines in HomingMissiles.js). The recovery sequence produced 6 clean commits (3 changes + 1 verify-update + 1 SPLASH_SOFT_CAP restore + 1 BUILD_VERSION bump). The lesson: small commits, narrow patches, verify between.
Mental shorthand: “Patch-and-commit sequence? Re-read the file before each patch. Grep before deleting. Verify between commits. If a commit bundles 3 concerns, split before pushing.”
Rule 108. Cap-reached surfaces as “weapon missing” — the legacy-fallback UI bug (2026-08-06)
When a gem-market card has the legacy two-gate shape (only weaponGateMessage declared, no gateReason or capGateMessage), the UI dispatch in GemMarket.js falls through to the weaponGateMessage for ANY failed gate. The cap-reached state surfaces to the player as “Buy Homing Missiles first” — even though the player has bought the weapon months ago. The player reads the message, looks at their shop, sees the weapon purchased, and concludes the gate is broken.
The bug as a user reports it:
“Missile Splash gem market card says ‘buy homing missiles first’ which I’ve already done.”
The user observed the cap gate failing (level >= cap) but the UI showed the weapon-gate message. Symptom: the player thinks the card is broken; underlying reality is the cap is doing its job but the UI is lying about which gate is failing.
The fix (per rule 100): migrate the card to the multi-prong shape:
// Before (legacy two-gate, blocks the "Need Xg" button at cap):
canPurchaseFn: (state) => {
const weaponPurchased = ...;
const underCap = lvl < 9;
return weaponPurchased && underCap;
},
weaponGateMessage: "Buy Homing Missiles first",
// After (multi-prong, the cap-reached state shows the cap message):
canPurchaseFn: (state) => _evaluateCardGate(state).ok,
gateReason: (state) => _evaluateCardGate(state).reason,
weaponGateMessage: "Buy Homing Missiles first",
capGateMessage: "Cap reached (Lv 9)",
The same fix applies to every legacy card with a cap. The capGateMessage priority is after the weapon-gate message — a player who hasn’t bought the weapon yet shouldn’t be told about the cap; they should buy the weapon first.
The pre-fix UX trap: the legacy fallback’s “Buy Homing Missiles first” message is misleading because the player CAN’T fix it by buying the weapon — they need to stop buying gem-market levels. The “Need Xg” button is the natural state for a purchase the player can act on; the lock message is the state for “can’t purchase.” Mixing them hides the cause.
The general rule (cross-feature): any UI dispatch that has a “fallback message” for unspecified failure modes is a footgun for the user. The cap message belongs to the cap gate; the weapon message belongs to the weapon gate; the silence / “Need Xg” is the “all gates pass but can’t afford” state. The fallback is a debug convenience that ships as a user-facing bug.
Migration checklist (when to apply rule 108):
- Identify legacy cards. Search
gemMarket.jsforweaponGateMessagedeclarations. Any card withweaponGateMessagebut nogateReasonis on the legacy shape. - Check for a cap. If the card has any
lvl < Ncheck incanPurchaseFn, the cap is a real gate; the legacy fallback misroutes the cap’s lock text. - Migrate to multi-prong. Add
gateReason,capGateMessage, and the helper. Update the description / effectText to remove the “soft cap” wording (“uncapped” for past-cap levels is the natural state). - Add the mirror-getter fix (rule 101): if the card has a runtime getter (e.g.
getSplash()), remove the cap from the getter too. Otherwise the UI says “uncapped” but the runtime still caps at the old value — dual-source-of-truth drift. - Verify the multi-prong dispatch. Confirm the gate-message priority is
weapon > cap(weapon first — the player can’t see the cap message until they’ve bought the weapon).
Caught by: the 2026-08-06 Sapper + Homing Missiles tuning thread, OG’s “Missile splash gem market card says ‘buy homing missiles first’ which I’ve already done.” The fix was the multi-prong migration for missileSplash, plus the unsplit missileSplash cap to “uncapped” per OG’s “I think the purchasable message is set to only that. Instead it should say ‘Need Xg’ with the right gem amount the way the others do.” Net: 2 commits (UI gate + runtime getter), 8 new verify assertions on the splash uncap.
Mental shorthand: “Cap-reached shows ‘buy weapon first’? Legacy fallback with no capGateMessage. Migrate to multi-prong.”
Rule 103. Description strings quoting numbers must source from the runtime constants
Any description string that quotes a number the runtime also uses (cooldowns, damage, ranges, costs, counts, radii) must derive that number from the runtime constant, not bake it as a literal in the string. Otherwise the description and the runtime drift independently and the player sees a tooltip that contradicts the actual behavior. Audit EVERY description site when a constant changes: the file’s top-level comment block, the shopDescription for the matching sub-card, and the currentValueFn if it embeds example numbers.
RULE 103 ADDENDUM (per OG 2026-08-08, Beacon Pulse DAMAGE_GROWTH 1.25→1.20 ship): when changing a growth constant, the popover example numbers don’t auto-recompute — they are hard-coded strings in the matching shopDescription / currentValueFn. The constant change is one patch; the example numbers are a second patch on the same commit. Real case: the popover text on Beacon Pulse’s beacon_pulse_damage sub-card was “level 5 reaches ~22.7, level 10 reaches ~43.1” — both numbers had to be re-derived from the new curve and patched in the same commit. Without the second patch, the popover says one thing and the production code does another; the player trusts the tooltip and overspends. Compute the new examples with a one-liner BEFORE patching the file:
node -e "const b=12, g=1.20; [5,10,20,30,40].forEach(l => console.log('L'+l, (b*Math.pow(g,l-1)).toFixed(2)))"
# L5 24.88, L10 61.92, L20 383.38, L30 2373.76, L40 14697.72
Trap applies to every growth-rate constant in the codebase: Beacon Pulse damage/duration/rate, Signal Flare damage/cooldown/speed, Chain Lightning damage/cooldown/decay, Homing Missiles damage/cooldown/radius, Spire Zone angle/slow/rotation, Lantern damage/count/speed. L40 verified math table for Beacon Pulse at references/beacon-pulse-l40-damage-math.md.
Sub-case (2026-08-06, Homing Missiles): sub-cards have THREE hard-coded surfaces, not one. When auditing a sub-card (shopSubCards entry) for hard-coded numbers:
- The
shopDescriptionstring itself (“Base 6.0s”). - Embedded example calculations in the description (“level 5: 3.94s, level 10: 2.57s”) — these are computed from the constant but written as literals.
- The
currentValueFn(level)at the base level returning'6.0s between salvos (base)'instead of${CONST}s between salvos (base).
Top-level fix is half a fix. The verify must read all three surfaces. For the Homing Missiles verify, the audit tested currentValueFn(0), currentValueFn(5), currentValueFn(10) to confirm the values reflected the runtime constant.
Full writeup (rule 102 + rule 103 sub-cases + rule 104): references/lessons-2026-08-06-sapper-homing-tuning.md.
Mental shorthand: “Description quotes a number? Pull from the runtime constant. Sub-card audit = three surfaces (description, embedded examples, currentValueFn).”
Rule 102. Take named parameters literally — don’t reinterpret scope
When OG names a parameter (“initial”, “default”, “first”, “subsequent”, “inter”, “between”), the parameter is rarely the scope. The scope is the underlying mechanism the user is targeting. Read the parameter, then identify the mechanism it modifies, then ask: does the user want the parameter to be the mechanism, or the mechanism to take a new value?
Sub-case (2026-08-06, Homing Missiles): “bump the initial salvo cooldown to 15 seconds” — the correct fix was MISSILE_BASE_COOLDOWN: 6.0 → 15.0, not a new MISSILE_FIRST_SALVO_DELAY_SEC constant. The named parameter “initial” was a modifier (the first of a series), not the scope (only the first). The scope was the base cooldown rate. The two readings are both internally consistent — both “make missiles less frequent” — but the first-salvo-delay fix leaves the cadence at 6s for every salvo after the first. The base-cooldown fix changes every salvo to 15s.
The two-commit test: before shipping, ask: “if I ship this, what does the SECOND salvo look like?” If the answer is “looks the same as before” and the user’s complaint was about the cadence, the fix is wrong.
The “new constant or new value?” test: does the new constant affect anything OTHER than the first occurrence? If no, the user’s intent is the existing constant.
Full writeup: references/lessons-2026-08-06-sapper-homing-tuning.md — rule 102 sub-case.
Mental shorthand: “User named a parameter? The parameter is rarely the scope. Identify the mechanism, then ask whether the user wants the parameter to BE the mechanism or the mechanism to take a new value.”
Rule 100. Multi-prong gate dispatch — gateReason + parallel *GateMessage fields
When a card has multiple gates (e.g. weapon-purchased + tier-milestone + soft-cap), the UI needs a structured reason to dispatch the right lock text per prong. The shape:
- Extract the gate logic into a shared helper that returns
{ ok, reason }. BothcanPurchaseFn(boolean, consumed byGameState.purchaseGemMarketCarddefense-in-depth) andgateReason(string, consumed by UI) call the same helper. Single source of truth — no drift between buy-side gate and UI dispatch. - Declare parallel
*GateMessagefields on the card data:weaponGateMessage,milestoneGateMessage,capGateMessage. The UI mapsgateReason→*GateMessagefield. - Multi-prong precedence: when multiple prongs fail, the UI shows the most-actionable message first (
weapon > milestone > cap). Rationale: a player who hasn’t bought the weapon yet shouldn’t be told about the milestone they need for a higher tier — they should buy the weapon first.
Cards without gateReason (the legacy chain/missile cards) fall through to the legacy single-message dispatch. Backwards-compatible — no regression for existing cards.
Full writeup: references/gem-market-gate-mechanic.md — sections “The multi-prong dispatch pattern (rule 100)”, “The shared-helper invariant (rule 28 + 100)”, “Multi-prong precedence: weapon > milestone > cap”, “Backward compatibility with legacy cards”, and the canonical Spire Zone Duplicates example with gateReason + _evaluateSzdGate helper.
Mental shorthand: “Multiple gates on the same card? Extract a helper, return { ok, reason }, declare parallel *GateMessage fields. The UI does the dispatch; the gate logic stays in one place.”
Rule 101. Dual-source-of-truth: enumerated consumers must move in lockstep with templated emitters (the Tier 4 shield-layer incident, 2026-08-06)
When a milestone emits a templated key (tier${N}_shield_aura, tier${N}_weapon_unlock, etc.) and another module looks up that key by hardcoded enumeration (Beacon._tierShieldBonusFlags(), catalog.js bonusGatedAny, etc.), the enumeration IS the schema. Extending one without the other is a silent no-op:
- The milestone fires.
- The bonus key lands in state.
- The consumer ignores the key because its enumeration stops at the prior tier.
- Symptom: “milestone fires but does nothing” — no error, no toast warning, no signal to the player. HUD stays at the old cap.
Diagnostic question to ask FIRST when a milestone “fires but does nothing”: does the consumer that should react to the bonus key actually know about the key? Search for the key name in the consumer (hasBonus, bonusGatedAny, _tierShieldBonusFlags). If the consumer has a hardcoded enumeration that wasn’t extended, the milestone is firing into the void. The fix lives in the consumer, NOT the milestone.
Avoid the misleading-narrative trap: when an override block visibly touches some waves (T4 overrides 25/100/1000) but skips others, it’s tempting to conclude “the override forgot wave 2584.” That’s a plausible narrative — and may be wrong. The scaffold (buildTierScaffold(N) in tierTemplates.js) auto-emits tier${N}_shield_aura at wave 2584 for any N. So the milestone IS firing the right key; the consumer just isn’t reading it. Verify before patching.
Canonical case: Tier 4 wave 2584 fired tier4_shield_aura correctly. Beacon._tierShieldBonusFlags() only listed T1/T2/T3 flags, so the 4th layer was emitted-by-milestone / ignored-by-looker. HUD stayed at 300% instead of going to 400%. T5-T10 had the same latent bug (same flag list, same scaffold). Fix is two-site: append the new flag in tier order to BOTH _tierShieldBonusFlags() AND the bonusGatedAny arrays in catalog.js, in the same commit. Reorder is forbidden (layer index N = flags[N-1]).
Full writeup: references/shield-layer-shape.md — “Defense when adding a new tier-shield milestone” section + “Pitfall: milestone firing ≠ layer unlocked (the Tier 4 incident, 2026-08-06)” subsection. Verification matrix includes T1+T2+T3+T4 and T1..T10 rows.
Mental shorthand: “Milestone fires but does nothing? Search for the key in the consumer’s enumeration, not the milestone emitter. If the consumer stopped at the prior tier, the fix is in the consumer, not the milestone.”
Rule 99. Every save-load path must run the migration loop (visible-but-inert trap)
When a codebase has multiple save-load paths (in this
project: GameState.deserialize() and SaveManager.load()),
the migration loop lives in one of them but the OTHER path
doesn’t run it. Any new schema entry added after the
divergent path was written becomes a silent-fail class for
existing saves: the entry’s consumers see undefined and
the user sees no error, no toast, no clue.
The “visible but inert” symptom: UI renders correctly
because the render path iterates the ROSTER (e.g.
GEM_MARKET_CARDS), not the state. The buy fails because
the buy path reads the STATE (e.g. metaUpgrades[key]).
When the roster and the state are out of sync for a specific
user, the symptom is always “the button shows but doesn’t
buy.” Always check the state, not the render, when the
symptom is “visible but inert.”
The fix shape: extract the migration into a method on
the data class (_migrateMetaUpgrades() on GameState),
then call it from EVERY save-load path. Two lines added in
two files. Verify by planting a save JSON with the old
schema and asserting all new keys are present post-load.
Full writeup: references/lessons-2026-08-06-save-migration-bypass.md
— includes the two-load-path diagram, the trap in the
investigation (code-path chase that wasn’t the bug), the
fix options, the verify recipe, and the cross-project
generalization. Ordering pitfall writeup (default-seed →
raw-assign → migrate → return is the right order; the
same-function case requires the migration to run AFTER
the raw assignment, not before):
references/lessons-2026-08-06-migration-ordering.md
— the silent-no-op that bit the kill-bucket fix on first
ship and was caught by the 3-assertion verify recipe.
The ordering pitfall (2026-08-06, kill-bucket variant): the
fix shape above says “extract _migrateMetaUpgrades() and
call it from every save-load path.” That’s necessary but not
sufficient. The migration ALSO has to fire AFTER the raw-state
assignment, not before:
// GameState.deserialize pattern (WRONG — caught by verify):
gs._migrateMetaUpgrades(); // seeds 11 entries
gs._migrateRunStats(); // seeds 6 buckets
gs.state.meta = raw.meta ?? gs.state.meta;
// ... then the deserialization fallback later writes the
// raw shape over the seeded default:
gs.state.runStats = raw.runStats ?? { ... }; // 4-bucket
// raw save
// WIPES the
// 6-bucket
// default the
// migration
// just seeded
The migration that runs BEFORE the raw-assign looks correct in
isolation (“yes, the helper ran”). But the next statement
(gs.state.runStats = raw.runStats ?? {...}) overwrites the
helper’s work with the raw 4-bucket save, wiping the
backfilled buckets. The bug is silent: the helper ran
without error, the bucket count was right at the moment of
the helper, and the wrong-state surfaces three lines later
when the raw assignment hits.
The correct order (right-side of the rule):
// GameState.deserialize pattern (RIGHT — caught by verify):
gs.state.meta = raw.meta ?? gs.state.meta;
// ...
gs.state.runStats = raw.runStats ?? { ... };
// migrations run AFTER every raw-state assignment so they
// see the loaded shape, not the default:
gs._migrateMetaUpgrades();
gs._migrateRunStats();
return gs;
The rule: migrations on the deserialize path must run AFTER
the corresponding raw assignment, not before. Treat the
deserialize function as a “load → migrate” sequence where
“load” includes every per-field raw-to-state assignment
(raw.runStats = ..., raw.weaponEnabled = ...,
raw.meta.lastOilRate = ...) and “migrate” runs once, at
the end, before return gs.
The _migrateMetaUpgrades case didn’t trip this because
Object.assign(this.gameState.state, merged) in
SaveManager.load() runs BEFORE the migration call, so the
migration sees the loaded state. The _migrateRunStats case
in GameState.deserialize() DID trip it because the raw
assignment was inside the same function, AFTER the (initially
placed) migration call. When the raw assignment and the
migration are in the same function, the rule “AFTER” matters;
when they’re in separate paths (load vs deserialize), the
ordering is enforced by the call site.
The verify that catches this (canonical recipe): in the
save-load migration verify, plant a raw save with the OLD
shape (pre-bug-fix buckets = pre-bug-fix entries) and assert
that the post-load state has the NEW shape backfilled. The
verify assertion state.state.runStats.killsByWeapon.chain_lightning === 0 (not undefined) catches the ordering bug directly.
Without that assertion, the migration “ran successfully”
without observable effect. The killstats-runstats-migration-verify.mjs
Sections 4 + 5 are the canonical recipe — plant a 4-bucket
old save with lantern=7, deserialize, assert ALL 6 buckets
present AND lantern===7 (preserve check). The combination
catches both “migration didn’t run” and “migration ran but
got wiped.”
Mental shorthand: “Buy button shows but doesn’t buy? The state is missing the entry. Check whether the save-load path runs the migration loop. If not, the migration is invisible to existing saves. AND: if the migration lives in the same function as the raw assignment, it must run AFTER the assignment, not before — otherwise the raw value wipes the migrated default.”
Rule 102. Take named parameters literally — the named parameter IS the scope
When OG states a tuning change using a named parameter (“bump the base cooldown to 15 seconds”, “set the radius to 80px”, “increase the unlock wave to 100”), the named parameter IS the scope of the change. Do not reinterpret the scope based on a plausible-narrative for “what the user probably wants.”
Anti-pattern (caught by 2026-08-06, “sapper and homing
missile tuning” thread): OG said “bump the initial salvo
cooldown to 15 seconds so not as many missiles are filling
the screen.” The assistant interpreted “initial salvo
cooldown” as a NEW constant
(MISSILE_FIRST_SALVO_DELAY_SEC = 15.0) that delays the
first salvo only, leaving the regular inter-salvo cooldown
unchanged at 6.0s. The shipped description read “After a
15s unlock delay, fires a salvo every 6s.” OG’s correction
arrived immediately: “No. I don’t want missiles every 6
seconds. I told you to adjust the base cooldown rate to 15
seconds.” The fix was to bump MISSILE_BASE_COOLDOWN from
6.0 to 15.0 — every salvo, not just the first.
Why the misinterpretation happened: the phrase “initial salvo cooldown” was treated as a NEW property (“initial salvo = first salvo; cooldown = 15s”). The literal reading is “the base cooldown = 15s applied to the salvos in this weapon.” The plausible-narrative read was “the user wants the first salvo delayed so the unlock moment reads, but the rest of the cadence stays the same.” The plausible narrative was wrong.
The rule: before introducing a new constant or a new delay, ask: does the named parameter already exist in code? If yes, the fix is to change its value, not to add a new one. The named parameter is the scope.
Diagnostic questions to ask BEFORE shipping:
- Does the named parameter exist in the source file? If yes, the fix is to change its value. If no, the user is asking for a new capability (which should be confirmed).
- Is the named parameter a “first/initial” qualifier, or a “base/rate” qualifier? “Initial” suggests first-only scope; “base” suggests applies-to-all scope. When OG says “initial X to N”, the literal read is “the X that applies to the initial state” — which is often the base parameter, not a new first-only parameter.
- When in doubt, ask before introducing the new constant. The cost of a 1-line clarification is far less than the cost of a rollback commit when the interpretation is wrong.
Composition with rule 89 (generalize before scoping the fix): rule 89 catches the inverse — a literal-example scope that should be generalized. Rule 102 catches the literal-example REINTERPRETATION — a literal-named parameter that should be applied literally, not narrowed to the example. The two rules compose: prefer the literal named-parameter scope, and when the named parameter is general, apply the rule to the whole class.
Caught by: the 2026-08-06 Homing Missiles tuning
thread. The original f45d8ba commit added
MISSILE_FIRST_SALVO_DELAY_SEC (a new first-salvo-only
constant) and left MISSILE_BASE_COOLDOWN at 6.0s. The
rollback commit bb1518b reverted the new constant and
bumped the base to 15.0s. Net: 2 commits, 1 line of
correct code, 1 line of misleading code deleted. The
lesson is “don’t introduce a new constant when the named
parameter already exists.”
Mental shorthand: “User named a parameter? That parameter is the scope. Change the existing parameter, don’t add a new one. When in doubt, ask.”
Rule 103. Description strings quoting numbers must source from the runtime constants
When a shop, gem-market, or tooltip description includes a specific number (cooldown, radius, damage, count, etc.), the description string MUST source that number from the same runtime constant the value comes from. Hard-coded literals in the description string desync silently when the runtime constant is tuned.
Anti-pattern (caught by 2026-08-06, “sapper and homing
missile tuning” thread): the Homing Missiles
shopDescription started as
'Every ' + MISSILE_BASE_COOLDOWN + 's, ...' — which is
correct in shape but reads the constant at constructor
time. When OG bumped the base cooldown from 6.0 to 15.0,
the description string was the last place to update (the
description still read “Every 6s” in the shop UI). The
correction: “The shop description still says the old
cooldown value.” The fix was to keep the description
referencing the constant — which it already did — but the
previous ship had introduced a NEW constant
(MISSILE_FIRST_SALVO_DELAY_SEC) and the description was
re-crafted to mention both. When the new constant got
reverted, the description reverted with it. The lesson:
the description must source from the SAME constants the
runtime uses, and the runtime must use ONE constant per
tunable value (not split between “first” and “rest”
variants).
The pattern:
// RIGHT — description sources from the runtime constant.
this.shopDescription =
'Every ' + MISSILE_BASE_COOLDOWN + 's, fires a salvo of ' +
'homing missiles at the nearest enemies. ' +
'Each deals ' + MISSILE_BASE_DAMAGE + ' direct damage.';
// WRONG — description hard-codes the literal.
// After shipping, if MISSILE_BASE_COOLDOWN is bumped to 15s,
// the description still says "Every 6s" until someone
// remembers to update it.
this.shopDescription =
'Every 6s, fires a salvo of homing missiles...';
Why this is Rule-class, not Pitfall-class: the failure mode is silent at the runtime level (the gameplay uses the new constant, the description just lags). The user sees the description and not the gameplay, so the user’s first complaint is “the description is stale” — which is correct, but the symptom is misleading (the runtime is fine, the bug is in the docs). A rule is warranted because the bug class is large (every description that quotes a number) and the fix is durable (one pattern, applies to every shop card).
The verify that catches this: every shop/gem-market
verify should assert that the description contains the
runtime constant’s value, not a literal. The
homing-missile-description-verify.mjs (2026-08-06) is
the canonical example — it asserts the description
includes Every 15s AND the old unlock delay phrasing
is absent. Both assertions source from the runtime
constant, so future tuning changes auto-update the
description and the verify stays passing.
Composition with rule 101 (dual-source-of-truth): rule 101 is about enumerated consumers moving in lockstep with templated emitters (e.g. a milestone firing a key that an enumeration must read). Rule 103 is the same class of bug for documentation: the source of truth is the runtime constant, and any consumer (description, tooltip, UI label, verify assert) must source from it. The pattern is “one const, many readers” — and if any reader hard-codes a literal, the system desyncs.
When a literal IS appropriate: flavor text that doesn’t claim a specific number (“Homing missiles track nearest enemies and detonate on impact — start small, scale via shop upgrades”) doesn’t need to source from the constant. The rule applies specifically to claims that quote a number (“Every 6s”, “+25% damage”, “Cost: 1,500 oil”). The verifying test for the rule is: does the description contain a number that, if the runtime constant changed, would no longer match?
Caught by: the 2026-08-06 Homing Missiles tuning two-step:
f45d8baintroducedMISSILE_FIRST_SALVO_DELAY_SECbut the description still readMISSILE_BASE_COOLDOWN(6s).5bfe628updated the description to mention both constants explicitly.bb1518breverted the first-salvo-delay constant and bumpedMISSILE_BASE_COOLDOWNto 15s. The description, which now sources from the base constant, auto-updated to “Every 15s.” No further description edit needed.
Mental shorthand: “Description quotes a number? Source that number from the runtime constant. Hard-coded literals desync silently when the constant is tuned.”
Rule 109. Class-specific immunity from graduated weapon effects (2026-08-06)
When a weapon effect is graduated — different magnitudes based on how strongly the target is exposed to the effect (e.g. “0 wedges = full speed, 1 wedge = slow, 2+ wedges = stun”), and OG asks that a specific enemy class be immune to the strongest tier (“bosses should not be stunned by the spire zone overlap zones”), the naïve fix is to gate the strongest tier alone:
if (wedgeHits >= 2 && !isBoss) {
e.speedMult = 0.0; // STUN — bosses exempt
} else if (wedgeHits === 1) {
e.speedMult = slowMult; // SLOW
} else {
e.speedMult = 1.0;
}
The trap: the exempted class falls through every branch. With wedgeHits === 1 strict, a boss inside ONE wedge gets slowed normally — fine. But a boss inside TWO wedges skips the stun branch (exempt) AND skips the slow branch (=== 1 fails because wedgeHits is 2) — and falls all the way through to speedMult = 1.0. The boss is now immune to the entire zone. The “fix” silently makes bosses faster than before, not slower.
The correct fix: widen the lesser branch from === 1 to >= 1. The exempted class lands on wedgeHits >= 1 (either single or overlap) and gets the slow applied. The strong-tier branch keeps its stricter comparison. Net effect:
if (wedgeHits >= 2 && !isBoss) {
e.speedMult = 0.0; // STUN — bosses exempt
} else if (wedgeHits >= 1) {
e.speedMult = slowMult; // SLOW — bosses get this even in overlap
} else {
e.speedMult = 1.0;
}
The mental model: a graduated effect is a chain of comparison thresholds. When you exempt a class from one tier, audit the OTHER thresholds — the exempted class must still fall into the next-lower tier, not skip past it to the no-effect floor.
The verify shape: every class-immune fix needs THREE assertions, not one:
- Non-exempt class at strong-tier effect: still stunned/slowed/frozen as before. Regression guard.
- Exempt class at the SAME strong-tier geometry: gets the lesser effect, NOT zero.
- Exempt class at the LESSER tier (single wedge): gets the lesser effect normally. Confirms the exemption is specifically about the strong tier, not the whole zone.
For the boss stun exemption, the verify added cases (e) boss in overlap → ≈0.95 (slow, NOT 0.0) and (f) boss in single wedge → ≈0.95, alongside the existing (d) non-boss in overlap → 0.0.
Cross-feature applicability: any weapon with graduated tiers (Beacon Pulse could grow “1 halo = slow, 2 halo overlap = something stronger”; Signal Flare could grow “1 flare = taunt, 2 flares = stun”; Homing Missiles could grow “1 missile = damage, 2+ missiles in flight = splash”). The pattern applies whenever the next-tuning-pass adds a higher tier to an existing effect — exempt classes must be re-verified through every tier, not just the new one.
Anti-pattern: “exempt the class with an early-return before the graduated logic.” The early-return skips the lesser tiers entirely:
// WRONG: boss immune to the entire zone, not just the stun.
if (isBoss) {
e.speedMult = 1.0;
continue;
}
// ... graduated logic ...
The early-return makes the exempted class trivially immune — they walk through every wedge at full speed. For a boss-as-pressure-tool design, that’s worse than the original bug: the player can’t slow a boss at all.
Caught by: the 2026-08-06 “Bosses should not be stunned by the spire zone overlap zones” thread. The naïve && !isBoss fix was the right idea, but the else if (wedgeHits === 1) strict comparison silently made bosses immune to the slow branch in the overlap case. The fix: widen === 1 to >= 1, add the (e) and (f) verify cases. Net: 3 commits (SpireZone.js, verify with two new assertions, BUILD_VERSION bump), 12-line code change.
Mental shorthand: “Class-immune from a graduated tier? Widen the lesser branch from === N to >= N — the exempted class must fall through into the lesser tier, not skip past it to no-effect.”
Rule 110. Three-site schema drift for new weapon kill buckets (the Beacon Out weapon-list incident, 2026-08-06)
When a new weapon is added and its kills should be attributed on the Beacon Out modal, there are three independent sites that all have to know about the new weapon id. Missing any one of them produces a silent bug — the kill is attributed nowhere the player can see.
The three sites (kill-bucket case):
-
Attribution source whitelist (
WaveManager._onEnemyKilledlines 712-715). Readsenemy._killedByand maps to astatSource. The whitelist is the SEAT BELT — anything not in the list falls through to the'lantern'default bucket. New weapons must add their id ('chain_lightning','homing_missiles') to this whitelist. -
Schema-bucket init for
runStats.killsByWeapon. Three writers in this codebase:GameState.js:158-163— default state onnew GameState().GameState.js:737-742— deserialization fallback whenraw.runStatsis missing.WaveManager.js:195-207— full reset onnewRunStarted. The default-state and fallback writers tend to LAG thenewRunStartedwriter because they’re touched less often. Add the new weapon id to ALL THREE in the same commit, OR a_migrateRunStats()helper that backfills missing keys (rule 99 pattern).
-
Render list on the modal (
PrestigeManager.js:337-342weaponsList). Even if the bucket is populated, the modal only shows rows for weapons in this list. Add the new weapon id to the list with its{ key, label, needsUnlock }. TheneedsUnlockvalue is the milestone id (e.g.'4:25'for chain_lightning,'5:25'for homing_missiles).
The silent-no-op guard that hides site 2’s bug: in WaveManager._onEnemyKilled:
if (bucket[statSource] !== undefined) bucket[statSource] += 1;
This guard is correct defense-in-depth (it prevents undefined key creation if the schema is wrong), but it makes the “bucket missing from schema” bug INVISIBLE at runtime. Kills flow in, the bucket doesn’t exist, the guard says “fine, no-op,” the player wonders why their Homing Missiles didn’t show up on the end-of-run modal.
The rule: when adding a new weapon to kill attribution, ship THREE coordinated edits, not one:
// 1. Attribution whitelist (WaveManager._onEnemyKilled)
const statSource = (killSource === 'beacon_pulse' || ... || killSource === 'homing_missiles')
? killSource : 'lantern';
// 2. Schema bucket init — ALL THREE writers
runStats: { killsByWeapon: { ..., homing_missiles: 0 } }
// 3. Render list (PrestigeManager.weaponsList)
{ key: 'homing_missiles', label: 'Homing Missiles', needsUnlock: '5:25' }
Plus the rule-99 migration: a _migrateRunStats() helper on GameState (call from BOTH deserialize() AND SaveManager.load()) that backfills any missing bucket to 0. Existing saves won’t retroactively show their old Homing Missiles kills — but they won’t silently accumulate to undefined, and the next run starts clean.
The verify shape:
- End a run where each weapon has at least one kill. Confirm all rows render and have non-zero counts. Catches site 1 + site 3.
- Load a pre-existing save from before a weapon was added (
chain_lightningpredateshoming_missiles). ConfirmkillsByWeapon[weaponId] !== undefinedafter load. Catches site 2. - Specifically assert the load-path guard:
state.runStats.killsByWeapon.homing_missilesis0(number), notundefined. Catches site 2 + the no-op guard.
Composition with rule 99: the kill-bucket case is structurally identical to the _migrateMetaUpgrades case from rule 99 — a new schema entry with multiple writer sites, one of which (the new-run reset) got updated while the load paths silently didn’t. Same fix shape: a _migrateRunStats() helper, called from every save-load path. Treat rule 99 as the umbrella; this rule 110 is the kill-bucket specialization.
Composition with rule 101 (dual-source-of-truth): the three sites of this rule are the same shape as the dual-source-of-truth issue — the source of truth (the weapon id) lives in multiple places, and adding a new weapon touches all of them. Rule 101 covers the specific case of a milestone emitting a key that an enumeration must read; rule 110 is the generalization to any “new enum value crosses an N-site boundary.”
Caught by: the 2026-08-06 “Are chain lightning and homing missiles counted in the kill stats on the beacon out page” question. The answer was NO at the time — they silently no-op because the default-state and load-fallback runStats schemas only have 4 buckets (lantern, beacon_pulse, signal_flare, spire_zone), and the modal’s weaponsList only renders 4 rows. The newRunStarted writer at WaveManager.js:205-206 was the only site updated when each new weapon shipped.
references/lessons-2026-08-06-kill-bucket-three-site-drift.md— the three-site kill-bucket incident (WaveManager attribution + 3 schema writers + PrestigeManager render list).references/per-tier-survival-model.md— Rule 111. Closed-form Python recipe for “how far does the player get?” balance-feasibility questions. Captures the v1→v4 modeling cycle from the 2026-08-06 “spire math” session (boss attribution, contact-chunk dominance, at-spire fraction, Spire Zone wedge geometry, Beacon Pulse DPS sanity check). Use this whenever OG asks a per-tier survival question instead of spinning up the game.references/pure-fib-tier-curve.md— Rule 112. The Pure-Fib tier + linear wave curve (T1-T2 preserved, T3-T10 follow Fib, DMG uses sqrt(Fib) to keep per-hit damage survivable). The verify-script-first contract for any future tier/wave change. The framing-question meta-rule that unlocked the right answer (“growth curve, not hard wall”).
Mental shorthand: “Adding a weapon to kill attribution? Three coordinated edits — attribution whitelist, schema bucket init (all 3 writers), render list — plus the rule-99 migration helper. Missing any one = silent no-op the player can’t see.”
Rule 107 addendum: remote-diverged push — pull –rebase before pushing (2026-08-06)
Rule 107 covers the LOCAL multi-commit decompression case (your local history has duplicated or interleaved commits). The symmetric case: you’ve been working locally, your commits are clean, but the REMOTE has advanced (another session, another machine, or a CI bot pushed since you last fetched). git push fails with “Updates were rejected because the remote contains work that you do not have locally.”
The recovery path:
git pull --rebase. Linear history — your commits replay on top of the remote’s new tip. Conflicts surface one-by-one, commit-by-commit, the same narrow-context way rule 107’s local decompression did.- Re-run the verify after the rebase. If any conflict resolution touched code that the verify exercises, the verify catches it before push. The 2026-08-06 boss-stun fix rebase (32 commits replayed onto origin/main) re-ran the verify and it still passed — confirming the rebase was conflict-free at the code level.
git push origin main. Should fast-forward cleanly now.
Why not git pull --merge? A merge commit creates a divergent topology that future rebase operations will trip on. Rule 107’s local decompression already produced a linear history; preserve it through the pull. The exception is when conflicts are too tangled to resolve cleanly per-commit — at that point, fall back to a merge and accept the topology cost, but flag it for the next session.
Why not git push --force? Rewrites public history. The 32 commits in the boss-stun fix would have erased the remote’s 30 new commits if I’d forced — and those 30 commits likely contain other people’s work. Force-push is correct for LOCAL-only branches (the rule 107 decompression case) but not for shared main.
Pre-flight check: before git push, glance at git log origin/main --oneline -3 to check whether the remote has advanced since you last fetched. If yes, pull –rebase first. Cheap; avoids the rejected-push round-trip.
Caught by: the 2026-08-06 boss-stun fix, after 3 commits shipped locally (boss exemption, verify update, BUILD_VERSION bump). git push failed with the “remote contains work” error. git pull --rebase replayed all 32 local commits onto origin/main’s new tip, the verify passed post-rebase, git push succeeded. Total recovery time: ~10 seconds.
Mental shorthand: “git push rejected with ‘remote contains work’? git pull --rebase, re-run verify, push again. Never git push --force on main.”
Rule 112. Pure-Fibonacci tier curve + linear wave curve for infinite-game difficulty (2026-08-06)
The design rule. The game’s difficulty scaling must be designed against the infinite-game philosophy (Cookie Clicker / AdCap / Paperclips lineage): no hard wall, no “impossible to beat” tier, but the difficulty curve must NEVER flatten out past T5. The player is always chasing growth; the curve is always outpacing them at a rate they can barely match by farming runs and upgrading their tools.
Three coordinated knobs compose. Get all three right.
-
Per-tier modifier follows Pure-Fibonacci ratios. T_n’s HP multiplier is the integer Fib number Fib(n) anchored to the existing T2 value. T2 unchanged from prior balance; T3+ follows Fib:
Tier HP (× T2) Fib value DMG (sqrt(Fib) × T2 anchor) T1 unchanged — unchanged T2 unchanged — unchanged T3 Fib(2) 2 sqrt(2) × anchor T4 Fib(3) 3 sqrt(3) × anchor T5 Fib(5) 5 sqrt(5) × anchor T6 Fib(6) 8 sqrt(6) × anchor T7 Fib(7) 13 sqrt(7) × anchor T8 Fib(8) 21 sqrt(8) × anchor T9 Fib(9) 34 sqrt(9) × anchor T10 Fib(10) 55 sqrt(10) × anchor DMG uses sqrt(Fib) (sub-Fib), not Fib. Why: DMG is the per-tick contact damage and hybrid-explode spike; if it scales at the same rate as HP, the player takes lethal single-hit bursts before they can react.
sqrt(Fib)keeps individual hits survivable while cumulative wave DPS-in is lethal. -
Wave curve is linear, not sqrt. Replace
(1 + sqrt(W) × 0.5)with(1 + W × 0.005). Why: sqrt curves taper off at high waves (1.5× at W=1, only 1.4× per 10K from W=10K to W=20K) which lets the player out-scale the difficulty and cruise. Linear curves grow uniformly — past W=10K the new curve is strictly harder than the old one and the difficulty climbs indefinitely. -
Damage exponent stays around 0.6-0.7 on
(ratio, exponent). The exact exponent depends on how the wave curve + tier modifier compose; with linear wave + Pure-Fib tier, the safe exponent is 0.7 (raised from the old 0.6 to keep T10 contactDPS from falling behind HP). The verify must confirm DMG ratio per tier is ≤ HP ratio — if any tier has DMG ratio > HP ratio, the curve has a damage “spike” tier the player cannot survive.
The T1-T2 preservation rule. When the table transitions from the old values into Pure-Fib, T1 and T2 stay byte-identical to the prior table. The two intermediate transitions (T2→T3 ≈ 1.41×, T3→T4 ≈ 1.25×) are deliberate ramp-up ratios — the table transitions from the unchanged T1-T2 sub-φ values into the Pure-Fib region at T4. Without the ramp-up, the first Pure-Fib jump (1.42× → 1.62×) would feel like a difficulty cliff to existing players.
The verify-script-first contract. Before shipping any tier/wave scaling change, write a pure-Node verify script that reads the source files and asserts:
- T1-T2 byte-identical to prior values (preserved early game)
- T3-T10 follow Pure-Fib (each per-tier HP ratio is ≈ φ = 1.618 from T4 onward)
- T2→T3 and T3→T4 are documented ramp-up ratios, not Fib regressions
- Wave curve is linear (no
sqrt(W) × 0.5form remaining) - Damage exponent is the design target (0.7 with Pure-Fib tier + linear wave)
- Curve grows monotonically across 100K waves in T1, T5, T10 (no hard wall, no plateau)
- No tier has higher DMG ratio than HP ratio (per-hit damage stays below HP growth)
- Difficulty crossover vs prior curve at T1 and T5 is ≥ W=5000 (new curve is gentler below this wave, harder above — the early game is preserved; T10 is harder from W=1 by design since per-tier scalar is the largest in the game)
Run the verify BEFORE the change (it should FAIL — that’s the proof the test plan catches regressions). Then apply the change. Run again. 0 failures = safe to ship.
The meta-rule (cross-cutting): before building any difficulty/math model, ASK THE FRAMING QUESTION FIRST. “What should this feel like — growth curve with no upper bound, a finite ceiling at some hard wave, or a plateau at endgame?” The shape of the difficulty model is determined by the answer, not by what the math naturally produces. Defaulting to “compute the upper bound” without asking = a model that justifies whatever the math happens to produce, which is anti-design. The 2026-08-06 thread landed on this lesson because a too-easy model almost shipped before OG explicitly named the design intent.
Mental shorthand: “Infinite-game difficulty = Pure-Fib tier × linear wave + ask framing question first. Never design a hard wall.”
Full writeup: references/pure-fib-tier-curve.md — the design rationale, the verify recipe, the v1→v4 modeling evolution, and the framing-question that unlocked the right answer.
Rule 111. Closed-form per-tier survival model for balance-feasibility questions (2026-08-06)
When OG asks a “how far does the player get?”-style question for this project (e.g. the 2026-08-06 ‘Let’s do some spire math’ thread: ‘how many waves would they be expected to survive in each tier before beacon death?’), the right move is to build a Python steady-state model that pulls every formula from the live code, NOT to spin up the game. The full recipe, including the 4 landmines (boss attribution, contact-chunk dominance, at-spire fraction, Spire Zone wedge geometry) and the ‘Beacon Pulse dominates DPS at max levels’ sanity check, lives in references/per-tier-survival-model.md.
Why this is Rule-class, not Pitfall: every future tier-balance change will prompt a similar question (‘what’s the new ceiling?’ / ‘does this shift the wall by N tiers?’). The technique is reusable across any (wave, tier) lookup. The landmines above are silent — they don’t crash the model, they just produce wrong numbers that look plausible. A rule locks the technique to the project and names the pitfalls so the next balance question doesn’t repeat the v1→v4 debugging cycle.
Mental shorthand: “OG asks how far does the player get? Build a closed-form survival model from the live formulas; don’t spin up the game (that’s the offline equivalent of rule 72).”
Rule 113. Wave 5000 = gem-market game-speed gate for ALL tiers T1+ (2026-08-06)
Per OG (2026-08-06, thread #1535359593901723668): wave 5000 milestones unlock gem-market game-speed upgrades for ALL tiers T1+, including future tiers built via tierTemplates.buildTierScaffold(). Three coordinated changes ship together:
- Label: “Tier Threshold” → “Tier Speed Threshold” across all 10 tiers.
- Effect:
milestoneToastonly →{ type: "gemMarketUpgrade", key: "gameSpeed", amount: 1 }+milestoneToast. - Toast: “How high can you climb?” → “The Spire quickens. How high can you climb?”
The previous “wave 5000 is the explicit exception to every-milestone-has-an-unlock” rule (OG 2026-07-27) is superseded. The new effect type gemMarketUpgrade persists to state.state.unlockedGemMarketUpgrades[key].level and stacks across tiers (matching the existing gem_find / damage_boost pattern). A player who crosses wave 5000 in every tier gets gameSpeed.level = N.
The scaffold-regeneration invariant: when buildTierScaffold(N) is updated for wave 5000, the new content applies to all tiers built via the scaffold (T4+ today, T11+ when the ORDINALS table extends). The wave-5000 scaffold entry is verbatim across tiers — TIER_4_MILESTONES, TIER_6_MILESTONES, TIER_10_MILESTONES all produce the same wave-5000 shape. Verified live: JSON.stringify(get(t4, 5000)) === JSON.stringify(get(t10, 5000)). The rule survives future tier additions for free.
Composition with rule 100 (multi-prong gate dispatch): the gem-market game-speed card (when it ships) is the consumer of this gate. Per rule 92’s “buy-side and select-side gates read the same field” invariant, both gates must read countTiersWithWave(state, 5000) — not metaUpgrades.gameSpeed.level (the buy count, which is output of the gate, not source of truth). unlockedGemMarketUpgrades.gameSpeed.level is the diagnostic-readable counter, not the gate source.
Full writeup: references/milestone-cadence-wave5000.md — the canonical 13-slot × 10-tier cadence table, the new gemMarketUpgrade effect handler, the verify recipe (4 sections), the OOM-stub pitfall (rule 88), the relationship to rules 82/90/92/99/100/111, and the remaining 16 open TBD slots after this rule shipped.
Mental shorthand: “Wave 5000 across all tiers = gem-market game-speed gate. The effect type is gemMarketUpgrade. The level stacks. The scaffold inherits the rule for future tiers.”
Rule 114. Believe before proving — grep for existing consumers before designing new effect types (2026-08-06)
When OG assigns design intent that suggests a new system (“wave-5000 unlocks gem-market game-speed upgrades”), the first instinct is to design a new effect type, a new data field, and a new handler. Before any of that, grep the codebase for the existing consumer that should already read the new gate. If the consumer exists, the fix is to wire the new gate field to the existing canPurchaseFn / currentValueFn / HUD picker — NOT to invent a parallel data path that nothing reads.
The trap (caught 2026-08-06, thread #1535359593901723668): OG said “technically all the 5000 milestones unlock gem market game speed upgrades.” The first ship introduced gemMarketUpgrade effect type, _applyEffects case, state.state.unlockedGemMarketUpgrades field, and a label rename — all dead code. The Game Speed card already existed in src/economy/gemMarket.js as a real card in GEM_MARKET_CARDS, with its own canPurchaseFn, currentValueFn, costFn, and a HUD picker in src/ui/GameSpeedButton.js. The card’s gate was countTiersWithWave(state, 5000) — a different field. My new effect type wrote to state.state.unlockedGemMarketUpgrades.gameSpeed.level, which no consumer read. OG’s reply included a screenshot of the actual card: “Seems like it exists to me.”
The full wiring chain (5 sites, not 1): when a milestone effect gates an existing card, the fix touches:
gemMarket.js canPurchaseFn— read the new gate field, not the old heuristicgemMarket.js currentValueFn— same source, for the displayed tier countsrc/ui/GameSpeedButton.jspicker — enable/disable speed steps from the same fieldsrc/ui/GameSpeedButton.jscosmetic “at-max” indicator — same fieldsrc/core/GameState._migrateUnlockedGemMarketUpgrades()— seed old saves frombestWaveByTier, called from BOTHdeserialize()andSaveManager.load()per rule 99
Without all 5, the new milestone fires but the card’s gate stays on the old heuristic, OR old saves load with the field undefined.
The grep recipe (run BEFORE designing the new effect type):
# 1. Gem market consumers
grep -n "GEM_MARKET_CARDS\s*=\|gameSpeed\|Game Speed" src/economy/gemMarket.js src/ui/GemMarket.js
# 2. HUD elements that read the gate
grep -rn "countTiersWithWave\|gameSpeed\|gameSpeedMult" src/ui/
# 3. Save-load paths (for the rule-99 migration)
grep -n "deserialize\|_migrateMeta\|unlockedSystems\|load(" src/core/GameState.js src/saves/SaveManager.js
If grep returns existing consumers, the fix is to wire them — not to create a new data path.
The verify recipe (additional sections, beyond the milestone-only verify):
// 5. Gate behavior — confirm canPurchaseFn reads unlockedGemMarketUpgrades
const gameSpeedCard = GEM_MARKET_CARDS.find(c => c.key === 'gameSpeed');
const state3 = { state: { metaUpgrades: { gameSpeed: { level: 0 } }, unlockedGemMarketUpgrades: { gameSpeed: { level: 3 } } } };
for (let p = 0; p <= 3; p++) {
state3.state.metaUpgrades.gameSpeed.level = p;
assert(gameSpeedCard.canPurchaseFn(state3) === (p < 3));
}
// 6. Migration from old save — seed from bestWaveByTier
const oldSave = { bestWaveByTier: { 1: 5000, 2: 5000, 3: 3000, 4: 6000 } };
let seeded = 0;
for (const w of Object.values(oldSave.bestWaveByTier)) {
if (typeof w === 'number' && w >= 5000) seeded += 1;
}
assert(seeded === 3); // matches countTiersWithWave(state, 5000) on the same data
If canPurchaseFn still reads countTiersWithWave, assertion 5 fails when the field is set to 3 but bestWaveByTier doesn’t reflect 3 tier-unlocks — the heuristic would still gate on the old data, not the new field.
Anti-pattern to avoid: “designed a new data path because the user said ‘new system’.” The user said “unlock gem-market game-speed upgrades” — that’s a wiring change to an existing card, not a new system. The new-system read is a plausible-narrative trap (rule 102’s symmetric case).
Caught by: the 2026-08-06 wave-5000 redesign. The first ship committed 5 files (MilestoneSystem.js, tierTemplates.js, plus comment updates) as dead code. The second pass added 3 more files (gemMarket.js canPurchaseFn + currentValueFn, GameSpeedButton.js picker + indicator, GameState.js _migrate method, SaveManager.js call site) to actually wire the existing card. Net: 8 files touched across two passes, 1 extra round-trip because the first ship didn’t grep for the existing consumer.
Mental shorthand: “User says ‘X unlocks Y’? Grep for Y’s existing consumer FIRST. If Y already exists, the fix is to wire X to Y’s canPurchaseFn, not to design a new data path. A screenshot in the user’s reply is the authoritative source — never declare ‘Y doesn’t exist yet’ without grepping.”
Keyword disambiguation: this project vs Dark Spire
Both projects have ‘spire’ in their name. When OG says ‘spire math’ / ‘spire balance’ / ‘Beacon Pulse’ / ‘Chain Lightning’ / ‘Homing Missiles’ / ‘Sapper’ / ‘Spire Zone’ / ‘wave-persist’ → this skill (Spire-Defense). When OG says ‘Dark Spire’ / ‘narrative idle’ / ‘traces’ / ‘the spire consumes your light’ / ‘lore fragments’ → idle-game-design-canon. If unsure, check the working directory: Spire-Defense lives at /home/og/idle-games/spire-defense/ and is the actively-developed project; Dark Spire is at /home/og/wiki/entities/dark-spire.md and is on hold. The 2026-08-06 ‘spire math’ session loaded idle-game-design-canon by mistake — caught immediately on context read. Don’t repeat.
Mental shorthand: ‘Spire-Defense keywords (weapons, tiers, wave) → spire-defense-build-protocol. Dark Spire keywords (lore, traces, narrative) → idle-game-design-canon.’
Rule 117. Defer modal/screen render with queueMicrotask to break the BEACON_DEAD listener race (2026-08-07)
When an event fires synchronously inside a state-mutation call AND one of its synchronous listeners renders UI that reads state another listener writes, the modal captures a stale value (typically null). The first ship of rule 115 (“Killed by:”) exhibited exactly this bug: the modal rendered BEFORE WaveManager.update() finished its post-damage attribution, so state.state.lastBeaconKiller was always null when the modal read it.
The race pattern (the canonical example):
1. WaveManager.update() calls beacon.damage(pendingDmg)
→ Beacon.damage absorbs shields, calls state.damageBeacon(amount)
→ state.damageBeacon emits BEACON_DEAD if HP ≤ 0 (synchronous)
→ PrestigeManager._onBeaconDead listener runs (synchronous)
→ _showRunEndModal(earned) reads state.state.lastBeaconKiller
→ lastBeaconKiller is STILL null (not yet written)
→ Modal HTML has no "Killed by:" line
2. WaveManager.update() continues
→ post-damage attribution block writes lastBeaconKiller
→ TOO LATE — modal already built
The verify-pass-without-line symptom: the modal renders fine but the player never sees the new line. Tests that assert the post-state directly (gs.state.lastBeaconKiller === 'boss') pass; tests that assert the modal HTML contains the line FAIL (or pass vacuously if the test reads HTML before the modal mounts).
The fix: defer the modal render with queueMicrotask in the BEACON_DEAD listener:
// PrestigeManager._onBeaconDead (race fix):
queueMicrotask(() => this._showRunEndModal(earned));
Why this works: microtasks run on the same JS-task boundary but AFTER all synchronous code in the current turn completes. So:
beacon.damage()→BEACON_DEADfires →_onBeaconDeadqueues_showRunEndModalvia microtaskWaveManager.update()finishes its post-damage block → writeslastBeaconKiller- The microtask runs →
_showRunEndModalreads state → modal HTML captures the killer
The player sees no visible delay (sub-millisecond microtask), but the modal read happens in the correct order.
Why “predict death before damage” is the wrong fix. I tried first: read beaconHPBefore.lte(pendingDmg) BEFORE calling damage. If true, attribute the killer, then call damage. The intuition was to get the killer into state BEFORE the modal reads it.
The trap: when shields absorb the killing blow, HP never crosses zero but HP-before <= pendingDmg is true (the shield saved the beacon). The prediction would attribute anyway — “Killed by: Boss” appears for a beacon that survived. The shield capacity is internal to Beacon.damage; WaveManager can’t see it from outside. Any prediction from outside the Beacon object is wrong when shields are active.
Test 15 of tests/killed-by-attribution-verify.mjs caught this on first ship (shield-absorbed killing blow: killer field stays null, beacon survives). Caught before commit, but only because the verify covered the shield case.
The general anti-pattern: predict the state-write BEFORE the event fires. This breaks when the prediction can’t see all the inputs (shields, conditional logic, async side effects). Always defer; never predict.
The vacuous-pass verify pitfall (Pitfall O): when the modal render is deferred via queueMicrotask, tests that read the modal HTML immediately get an empty string. The assertion ''.includes('Killed by:') is false, so the test PASSES vacuously — the bug “no line in modal” looks identical to “no modal at all.”
The fix: await the microtask before reading HTML:
pm._onBeaconDead();
await Promise.resolve(); // wait one microtask for the modal to render
const html = document.getElementById('run-end-modal')?.innerHTML ?? '';
Promise.resolve() is the test’s way of waiting one microtask tick. The page.evaluate handler is async, so the await boundary gives the microtask queue a chance to drain before the HTML read.
Defense-in-depth assertion: add a second assertion that the modal IS rendered (HTML length > 0). Without this, future regressions where the microtask defer breaks can pass vacuously:
assert('modal IS rendered (HTML non-empty)', nullHtml.length > 0,
'defense: confirms the modal rendered (no vacuous pass)');
Cross-feature applicability. The “synchronous event triggers synchronous UI render that reads state written by another listener” pattern shows up in:
- Beacon Out modal (this rule, BEACON_DEAD race)
- Future: damage-taken toast (any “took damage from X” surface)
- Future: milestone-unlock toast (MILESTONE event triggers HUD popup, popup reads state that the milestone’s effect wrote)
The pattern of “defer UI render with queueMicrotask when the state-write is in another synchronous listener” is durable across any event-driven UI. Anti-pattern to avoid: predicting the state-write BEFORE the event fires — breaks when the prediction can’t see all the inputs.
When NOT to defer: _onResumeIntoBeaconOut is the load-into-modal path — no race because the death happened in a prior session and lastBeaconKiller was already set then. The microtask deferral is only needed in the synchronous death-listener path.
Mental shorthand: “Event handler reads state another listener writes? Defer the UI render with queueMicrotask. Never predict the write — shields/conditions/etc. make predictions wrong. Verify after deferring must await the microtask, or it passes vacuously.”
Full writeup: references/lessons-2026-08-07-beacon-dead-modal-race.md — the actual bug, the wrong-first-fix (predict-death), the right fix (defer), the vacuous-pass trap, and the verify additions (sections 14, 15).
Rule 116. Per-tier stun resistance with stochastic roll (2026-08-07)
When OG specifies that a class of enemies should gain resistance to a graduated weapon effect (stun, freeze, knockback) that scales BOTH by tier AND by wave-within-tier, the natural shape is: per-tier cap table + per-tier cap-reach wave table + per-tick stochastic roll + rule-109 fall-through.
1. Two-table shape (cap × cap-reach wave).
const STUN_RESIST_CAP = { 7: 0.90, 8: 0.90, 9: 0.90 };
const STUN_RESIST_CAP_WAVE = { 7: 15000, 8: 10000, 9: 5000 };
// T10: 1.00 (immune — bypasses the roll entirely)
// T1-T6: no entry → 0% (no resistance)
The cap determines HOW resistant (max effect); the cap-reach wave determines WHEN the cap hits. T7 reaches 90% at W15000 (slow climb, 0.6%/100W), T8 at W10000 (0.9%/100W), T9 at W5000 (1.8%/100W). The per-100W rate is derived: cap / (capWave / 100).
2. Linear interpolation, not multiplicative decay (rule 105 generalization).
The naïve read of “scales up with increasing wave count” is base * mult^wave, which hits the cap asymptotically — never exactly. Rule 105 applies: end-point-fixed tuning wants linear interpolation, clamped at the cap:
function stunResistPct(tier, wave) {
if (tier >= 10) return 1.00; // immune bypass
const cap = STUN_RESIST_CAP[tier];
if (cap === undefined) return 0; // T1-T6: no resistance
const capWave = STUN_RESIST_CAP_WAVE[tier];
if (wave >= capWave) return cap; // clamp at cap
if (wave <= 0) return 0;
return Math.min(cap, (wave / capWave) * cap); // linear ramp
}
T7 W15000 → exactly 0.90. T8 W10000 → exactly 0.90. T9 W5000 → exactly 0.90. No asymptotic drift.
3. Per-tick stochastic roll, not a deterministic gate.
OG framed it as “% chance to ignore” — every tick, the enemy rolls the dice. Even at 90% resistance there’s a 10% window where the stun still lands. This keeps the player hopeful (a critical-hit stun still rewards good positioning) and makes the system feel organic rather than binary.
function shouldIgnoreStun(tier, wave, archetypeName) {
if (!STUN_RESIST_ARCHETYPES.has(archetypeName)) return false;
if (tier >= 10) return true; // immune bypass
return Math.random() < stunResistPct(tier, wave); // stochastic roll
}
4. Rule 109 fall-through invariant.
When the roll says “ignore stun,” the enemy must still fall into the LESSER tier (slow), not skip past it to no-effect:
if (wedgeHits >= 2 && !isBoss) {
if (!shouldIgnoreStun(tier, wave, e.archetypeName)) {
e.speedMult = 0.0; // STUN
} else {
e.speedMult = slowMult; // SLOW — rule 109: exempted class still gets the lesser tier
}
}
This is rule 109 generalized: any “exempted from strong tier” gate must re-route to the next-lower tier, not skip past it. The boss-stun exemption (rule 109) and the splitter/sapper stun resistance (rule 116) share the same fall-through shape.
5. Cascade children inherit the resistance.
If the resisted enemy has a death-cascade (splinter → splinter_2 → splinter_3), the archetype set must include every cascade generation:
const STUN_RESIST_ARCHETYPES = new Set([
'splinter', 'splinter_2', 'splinter_3', 'sapper',
]);
Otherwise the resistance evaporates after the first death (splinter dies, spawns splinter_2 which is fully stunnable, the player gets a free kill). The verify must cross-check archetype-set coverage against the actual archetype registry.
6. The verify pitfall: thread rng, don’t mock Math.random.
The naïve way to make the roll deterministic in the verify is Math.random = () => 0.5. This is a global mock — it leaks across every assert in the file that follows until the author remembers to restore. The clean fix is to thread an rng parameter into the helper and inject deterministic fns per-section:
function shouldIgnoreStun(tier, wave, archetypeName, rng = Math.random) {
// ...
return rng() < stunResistPct(tier, wave);
}
// Per-section usage:
_shouldIgnoreStun(7, 7500, 'splinter', () => 0.5) // section 3 (single roll)
_shouldIgnoreStun(7, 15000, 'splinter', seededRandom) // section 5 (1000-trial stats)
The 2026-08-07 verify hit this exact leak on first ship (a function parameter was shadowed by the global Math.random mock), had to refactor the helper to take an injectable rng. Recorded in references/verify-script-pitfalls.md.
7. The source-vs-verify cross-check (rule 114).
The verify file should include a regex-anchor audit that asserts the source file actually contains the constants and helper functions the verify exercises:
import { readFileSync } from 'node:fs';
const src = readFileSync('../src/weapons/SpireZone.js', 'utf8');
function mustContain(needle, label) {
assert(label, src.includes(needle), `source missing: ${needle}`);
}
mustContain("STUN_RESIST_CAP = { 7: 0.90, 8: 0.90, 9: 0.90 }", 'cap table');
mustContain("'splinter_2', 'splinter_3'", 'cascade children included');
mustContain('function shouldIgnoreStun(tier, wave, archetypeName)', 'helper exported');
mustContain('shouldIgnoreStun(tier, wave, e.archetypeName)', 'stun branch wired');
If the source diverges (renames constants, removes an archetype, deletes the helper), the audit fails — the verify is no longer testing what the runtime does. Without this, a future refactor can silently break the verify (the helper no-ops on the wrong field, but the formula asserts still pass because they test the inline replication, not the source).
8. Verify recipe (28 assertions across 6 sections):
- Formula end-points (T7 W15000 → 90% exact, T8 W10000 → 90% exact, T9 W5000 → 90% exact, T10 any → 100%, T1-T6 → 0%)
- Cap shape (monotonic within tier, midpoint ≈ 45%, clamped at cap past the cap wave)
- Roll behavior at extremes (rng=0.5 at 50% resist → ignored, rng=0.95 at 90% resist → applies for the 10% window)
- Non-resisted archetype pass-through (boss, grunt → resist helper returns false)
- Statistical sanity (1000 trials at T7 W15000 → ignored ~90% ± 4%, T10 → 100% exact)
- Source-vs-verify cross-check (regex audit confirms the source contains the cap tables, helper functions, and stun-branch wiring — rule 114)
Caught by: the 2026-08-07 OG ask “Stun immunity for splitters and sappers in Tier 10. They should have stun resistance in Tiers 7, 8, 9 that scales up with increasing wave count so it is less effective over time.” The clarifying question established the per-tick stochastic roll shape: “% chance to ignore stun… scales slowly every 100 waves… cap at 90% chance to ignore stun in Tier 7 at wave 15000, Tier 8 at wave 10000 and Tier 9 at wave 5000.” Net: 1 source change (44 lines: 2 tables + 2 helper functions + 1 branch wiring), 1 verify file (28 assertions), no regression in existing spire-zone-duplicates-gem-market-gate-verify or CI smoke.
9. The missed-prerequisite trap (the T6+ spawn gap, 2026-08-07).
The rule 116 verify passes — but the feature shipped INVISIBLE in T6+ because no splinters/sappers were spawning past T5. Root cause: DEFAULT_TIER_DISTRIBUTION in src/wave/WaveManager.js:87-93 only defines T1-T5:
const DEFAULT_TIER_DISTRIBUTION = {
1: { common: 0.70, strong: 0.25, hybrid: 0.05, ranged: 0.00 },
2: { common: 0.55, strong: 0.25, hybrid: 0.10, ranged: 0.10 },
3: { common: 0.45, strong: 0.25, hybrid: 0.10, ranged: 0.20 },
4: { common: 0.30, strong: 0.20, hybrid: 0.10, ranged: 0.20, splitter: 0.20 },
5: { common: 0.25, strong: 0.20, hybrid: 0.05, ranged: 0.20, splitter: 0.25, sapper: 0.05 },
};
_pickArchetype falls through to T1’s mix for any tier ≥ 6:
const dist = this.opts.tierDistribution[tier] ?? this.opts.tierDistribution[1];
T6+ spawns ONLY common/strong/hybrid/ranged. Zero splinters, zero sappers. The rule 116 stun-resist code is correct in isolation but has zero enemies to act on. OG noticed: “I’m not seeing any of the enemies break through stun on Tier 10.” Verify caught the spawn gap only because I happened to look at the screenshot and grep’d for the tier distribution.
The grep that should have run before shipping rule 116:
# 1. Does the archetype actually spawn at this tier?
grep -n "DEFAULT_TIER_DISTRIBUTION\|tierDistribution" src/wave/WaveManager.js
# 2. Does the tier index in the stun-resist cap table (T7, T8, T9, T10)
# have a corresponding tierDistribution entry?
node -e '
const d = { 1:{...},2:{...},3:{...},4:{...},5:{...} };
const tiers = Object.keys(d).map(Number);
console.log("Distribution covers tiers:", tiers.join(","));
console.log("Resist cap covers tiers: 7, 8, 9, 10");
console.log("Gap:", [7,8,9,10].filter(t => !tiers.includes(t)));
'
The rule: when a per-tier feature references a tier index (7, 8, 9, 10), grep for that tier’s other schemas (spawn distribution, milestone scaffold, gem-market card gate). The tier curve may go to T10 but the spawn table, milestone scaffold, or gem-market card may stop at T5. Per-tier features are typically N consumers wide — the verify must assert each consumer exists, not just the one the source change touches.
The verify extension: add a section that cross-checks the rule-116 cap-table tiers against the spawn distribution’s tier keys:
// Section 9 — prerequisite consumer check
const SRC_WM = readFileSync('../src/wave/WaveManager.js', 'utf8');
const distMatch = SRC_WM.match(/DEFAULT_TIER_DISTRIBUTION\s*=\s*\{([^}]+)\}/);
const distTiers = [...distMatch[1].matchAll(/^\s*(\d+):/gm)].map(m => Number(m[1]));
const resistTiers = [7, 8, 9, 10];
for (const t of resistTiers) {
assert(
`tier ${t} in DEFAULT_TIER_DISTRIBUTION (rule 116 prerequisite)`,
distTiers.includes(t),
`tier ${t} missing — _pickArchetype will fall through to T1 mix, no splinters/sappers spawn`,
);
}
This section would have CAUGHT the bug at ship-time. Without it, the rule 116 ship was 26 passing assertions on code that would never trigger.
Caught by: the 2026-08-07 follow-up OG observation “I’m not seeing any of the enemies break through stun on Tier 10.” Diagnosed via screenshot inspection (all enemies were circles = no splinter triangles or sapper stars), then grep for DEFAULT_TIER_DISTRIBUTION (only defined T1-T5). Net: rule 116 ship was functionally invisible in T6+; the fix requires extending the spawn distribution through T10.
Mental shorthand: “Class-specific graduated resistance? Linear interpolation to per-tier cap + per-tick stochastic roll + rule-109 fall-through. T10 = immune bypass. Cascade children inherit. Thread rng through helper, don’t mock Math.random. Source-vs-verify cross-check via regex audit. AND: grep for the spawn distribution — per-tier features need per-tier spawn data to act on.”
Full writeup: references/lessons-2026-08-07-splitter-sapper-stun-resistance.md
Rule 115. Killer-blow attribution via per-tick contribution map (the Beacon Out “Killed by:” line, 2026-08-07)
Per OG (2026-08-07, “Beacon Out screen adjustment”): the player wants to know what killed the beacon on the Beacon Out modal. Beacon damage is accumulated per tick (pendingDmg in WaveManager.update()) across all enemies at the spire, then applied in one call to Beacon.damage() (which routes through shield layers, then state.damageBeacon()). So a single tick can kill the beacon with multiple enemies contributing — there’s rarely one literal “killing enemy.”
The per-tick contribution map (the durable pattern):
// WaveManager.update() — inside the existing enemy loop:
const tickDmgByEnemy = new Map(); // enemy instance → dmg this tick
for (const enemy of this.enemiesAlive) {
// ...
const auraDmg = enemy.tickAura(dtMs);
const dpsDmg = enemy.tickContactDPS(dtMs);
if (auraDmg > 0 || dpsDmg > 0) {
const sub = auraDmg + dpsDmg;
pendingDmg += sub;
tickDmgByEnemy.set(enemy, (tickDmgByEnemy.get(enemy) ?? 0) + sub);
}
}
// After applying pendingDmg to the beacon:
if (
this.beacon && this.beacon.getHP().lte(0) &&
tickDmgByEnemy.size > 0
) {
let topEnemy = null, topDmg = 0;
for (const [enemy, dmg] of tickDmgByEnemy) {
if (dmg > topDmg) { topDmg = dmg; topEnemy = enemy; }
}
if (topEnemy?.archetypeName) {
this.state.state.lastBeaconKiller = topEnemy.archetypeName;
}
}
Three structural choices that matter:
-
Map keyed by enemy instance, not archetype. Multiple enemies of the same archetype can be at the spire; you want to attribute the instance with the most damage this tick, then read its
archetypeName. Keying by archetype would conflate contributions from two Sappers — you’d never know which one was the killer. -
Read HP AFTER damage is applied.
if (this.beacon.getHP().lte(0))after theBeacon.damage(...)call. If you read HP before, shields that absorbed the killing blow will false-fire the attribution — the beacon didn’t actually die. -
Guard with
tickDmgByEnemy.size > 0. If beacon HP crosses zero for some other reason (future code path, edge case), the field stays untouched. Per OG’s “hide if no enemy contributed” rule — the modal line is silent rather than mis-attributing.
OG’s framing choice (per the rule-112 meta-rule): OG picked “the archetype that dealt the last hit, even if it wasn’t alone” over the alternatives:
- Composition: “Boss + 2 Splitter” — list all archetypes at spire
- Top contributor this run: “Sapper (3.2x multiplier)” — cumulative dmg
- Last hit (chosen): “A Boss” — top contributor of the killing tick
- Count: “Sappers (×3 of 5)” — count by archetype vs total alive
The choice determined the architecture: per-TICK attribution (last hit), not per-RUN (top contributor). The same tickDmgByEnemy data could have supported any of the alternatives — the framing question chose the read pattern.
Three coordinated sites (rule 110 generalization):
When adding lastBeaconKiller to the schema, three writers needed updates + a migration helper. The new field lives at top-level state.state.lastBeaconKiller, NOT inside runStats — it’s a “what killed me” diagnostic, conceptually distinct from per-run kill attribution.
| # | Site | What ships |
|---|---|---|
| 1 | WaveManager.update() |
Per-tick map + post-apply attribution |
| 2 | WaveManager.newRunStarted listener |
Reset to null on Begin New Run |
| 3 | GameState default state |
lastBeaconKiller: null |
| 4 | GameState.deserialize fallback |
gs.state.lastBeaconKiller = raw.lastBeaconKiller ?? null |
| 5 | _migrateRunStats() helper |
if (this.state.lastBeaconKiller === undefined) this.state.lastBeaconKiller = null; — called from BOTH load paths per rule 99 |
The reset-to-null placement (rule 110 sub-case): the newRunStarted reset writes the entire runStats object literal (this.state.state.runStats = { tier, waveReached, killsByWeapon, lightRecovered }). Adding a sibling field lastBeaconKiller at top-level requires a SEPARATE assignment line after the runStats reset:
this.state.state.runStats = { ... }; // (resets the bucket object)
this.state.state.lastBeaconKiller = null; // (sibling reset, not nested)
this.startNextWave();
Don’t try to fold lastBeaconKiller into the runStats reset — it’s not a run-stat. Don’t try to add a nested field either — it’s top-level state. The sibling assignment is the correct shape.
The migration-ordering subtlety (rule 99 ordering-pitfall): the deserialize fallback raw.lastBeaconKiller ?? null runs BEFORE _migrateRunStats(). The migration’s === undefined check fires AFTER the raw assignment. If the raw value is 'sapper', ?? null doesn’t overwrite (sapper is non-nullish), then the migration’s === undefined check fails (it’s a string), no overwrite. If the raw value is missing/undefined, ?? null sets null, then the migration’s === undefined check is false (it’s null), no overwrite. Net: the migration NEVER overwrites a real or null value — it only backfills undefined. This is the same “migrate AFTER assignment” pattern rule 99 documents for _migrateRunStats(), applied to a sibling field.
The hide-if-no-contribution rule (UX): when state.state.lastBeaconKiller is null OR undefined, the modal hides the “Killed by:” line entirely. No “Unknown” placeholder, no “—” filler. Per OG: “the player shouldn’t see a fake attribution on legacy saves.” Same pattern works for future diagnostics — if the attribution source is missing, hide the line; don’t lie.
KILLER_LABEL display-name map: a top-of-file const KILLER_LABEL = { boss: 'Boss', sapper: 'Sapper', ... } covers every current archetype. Unknown archetypes fall back to the raw archetype name (no undefined rendered). Future archetype additions are a one-line append to the map. The verify cross-checks Object.keys(ARCHETYPES) against KILLER_LABEL keys so a future add can’t ship without a label entry.
The verify shape (13-section recipe):
- KILLER_LABEL coverage (regex against source file, every ARCHETYPES key present)
- Default state:
lastBeaconKiller: nulldeclared in source - Migration: undefined → null on legacy save (idempotent)
- Migration: preserves real attribution (does NOT overwrite ‘boss’ with null)
- GameState.deserialize: carries raw value through, backfills missing → null
- Modal renders correct label for all 10 archetypes (including splinter cascade)
- Modal HIDES line for null killer
- Modal HIDES line for undefined killer (legacy save)
- KILLER_LABEL fallback: unknown archetype renders raw (no undefined)
- WaveManager attribution: top dmg enemy wins on death tick (3 enemies at dmg 3/2/1, beacon HP at 5 — boss wins)
- No-enemy-at-spire → no attribution change
- newRunStarted resets to null
- Three-site schema coverage regex audit (rule 110)
Each section is independently runnable; failures point at the exact site that regressed.
Cross-feature applicability: any system where per-tick aggregation happens AND the player benefits from “who contributed to X” diagnostics. Examples in this codebase:
- Beacon Out “Killed by:” (this rule)
- Future “Most damaging weapon per run” — same per-tick map, summed across the run
- Future “Most damaging enemy archetype per run” — same per-tick map, summed + grouped by archetype
- The pattern generalizes: track per-instance contribution, read AFTER aggregation, attribute to top contributor
Composition with rule 106 (homing-projectile lose-target): rule 106 is “detect when a target dies mid-flight.” Rule 115 is “detect who contributed to the kill.” Both are per-tick + post-aggregation detection patterns. The shared shape: Map<instance, tick-value> + check the post-state + attribute to the relevant instance. Future “who broke my shield layer” or “which enemy was the last to deal damage to me before I died” diagnostics follow the same template.
Mental shorthand: “Per-tick ‘who contributed’ diagnostic? Map<instance, dmg-this-tick>, attribute top contributor AFTER applying aggregation. Read HP after damage, not before. Hide if no contributors. Reset to null on new run. Migration backfills undefined → null, never overwrites.”
Full writeup: references/lessons-2026-08-07-killed-by-attribution.md — the worked example, the framing-question chain (4 clarify() calls), the stub-state verify pitfalls (Pitfalls M, N in references/verify-script-pitfalls.md).
Rule 118. Count-based gem-market unlock via per-tier gemMarketUpgrade stacking (2026-08-08)
When OG asks for a gem-market card whose level unlocks one at a time across multiple tiers (e.g. “Lv1 at T4W1000, Lv2 at T5W1000, …, Lv5 at T8W1000”), the right shape mirrors the gameSpeed pattern (rule 113). Two consumers in the codebase share this design:
| Card | Tier band | Wave gate | Stack count |
|---|---|---|---|
gameSpeed |
T1-T10 (all tiers) | wave-5000 | 10 (= number of tiers) |
spireZoneDuplicates |
T4-T8 (Spire-Zone-relevant band) | wave-1000 | 5 (= number of levels in cap) |
The pattern:
- Each tier’s wave milestone fires
gemMarketUpgrade { key, amount: 1 }(appended to the milestone’seffects[]array — non-disruptive). The tier MUST be in the card’s scope band; tiers outside the band do NOT include the effect (would over-unlock the cap). _applyEffectswrites tostate.state.unlockedGemMarketUpgrades[key].level— the level stacks across tiers (matching the existinggem_find/damage_boostpattern).- Card
canPurchaseFnreadsunlockedGemMarketUpgrades[key].level, notbestWaveByTierorunlockedMilestones. Lv N is purchasable whenunlockedLevel >= currentLevel + 1. _migrateUnlockedGemMarketUpgrades()backfills the field on load by counting qualifying tiers inbestWaveByTier(rule 99 invariant — called from BOTHdeserialize()andSaveManager.load()). Existing saves don’t retroactively gain unlocked levels (the count is a one-time read at load), but they don’t silently reset either.
Why this beats the heuristic pattern (countTiersWithWave / unlockedMilestones.includes(...)): the milestone-driven level field is the authoritative source — it can’t drift from bestWaveByTier (which can advance past the gate without firing the milestone if wave skip is on, etc.). Rule 113 already corrected the gameSpeed case to use the milestone field after the heuristic was found off-by-one.
The scaffold-regeneration invariant: when a tier’s wave-gate milestone is overridden in MilestoneSystem.js (T4 is the canonical case — it has explicit per-wave overrides for wave-25 / 100 / 500 / 1000), the override MUST include the gemMarketUpgrade effect. T5-T10 use buildTierScaffold(N); their wave-1000 placeholder is a generic TBD toast that ships with the override-loop pattern. The loop must come AFTER the TIER_5/6/7/8/9/10_MILESTONES const declarations — see rule 119 for the TDZ trap.
Cap-vs-milestone precedence: when both prongs fail (player at Lv 5 with 5/5 crossings), the cap prong fires first. “Reach more crossings” is misleading when no more crossings would help (T9/T10 wave-1000 don’t include the spireZoneDuplicates effect — they would over-unlock the 5-level cap). Reorder the gate helper to check underCap BEFORE the milestone prong. This is the inverse of rule 100’s “weapon > milestone > cap” precedence, applied to the at-cap case.
Dynamic *GateMessageFn(state) companion: when the lock text is state-dependent (e.g. “Reach N more tier-w1000 crossings” — N varies per level, per state), declare a milestoneGateMessageFn(state) function alongside the static milestoneGateMessage field. The UI dispatch prefers the function. See references/gem-market-gate-mechanic.md “Dynamic lock text via *GateMessageFn(state)” section for the full shape and the worked SZD example.
Caught by: the 2026-08-08 “gate spire zone duplicate gem market upgrade purchases behind per-tier milestones” thread (#1535751073678233721). OG specified the exact 5-tier cadence. Implementation mirrored gameSpeed (rule 113). Net: 1 commit (3eb5361), 9 files, 60/60 new verify assertions + no regressions in 2 existing SZD verifies.
Mental shorthand: “Per-tier unlock ladder? Stack via gemMarketUpgrade on each tier’s wave milestone, read unlockedGemMarketUpgrades[key].level in the gate, scope the tier band to the card’s cap. Cap beats milestone at-cap. Dynamic *GateMessageFn surfaces the specific gap.”
Full writeup: references/lessons-2026-08-08-szd-per-tier-unlock.md — the worked example, the framing-question chain (2 clarify() calls), the TDZ bug I caught on first ship, the cap-then-milestone precedence exception, the dynamic-message extension, the 60-assertion verify recipe (11 sections), and the four “old verify” updates required by the rule 110 generalization.
Rule 119. Module-load override loops must come AFTER the const declarations (the TDZ trap, 2026-08-08)
JavaScript const declarations are not hoisted — they’re in the temporal dead zone until the declaration line executes. A for (const x of [TIER_5_MILESTONES, ...]) placed BEFORE const TIER_5_MILESTONES = buildTierScaffold(5) throws ReferenceError: Cannot access 'TIER_5_MILESTONES' before initialization at module load, even though the variables are referenced by closure inside the loop body.
The failure mode: the patch tool’s lint check sees the new code and the old code, flags no syntax error. The file looks fine in isolation. But when the bundle loads at runtime, every consumer of TIER_5_MILESTONES etc. fails with a TDZ exception — the entire MilestoneSystem.js module becomes inert. Downstream: no milestones fire, the game runs in an infinite “wave 1” loop with no unlocks. The user sees a frozen HUD with no errors logged.
The pattern that bit the 2026-08-08 SZD-unlock commit:
// BAD — loop references TIER_5_MILESTONES BEFORE the const.
for (const tierList of [TIER_5_MILESTONES, TIER_6_MILESTONES, ...]) {
for (const ms of tierList) {
if (ms.atWave === 1000) { /* add gemMarketUpgrade effect */ }
}
}
export const TIER_5_MILESTONES = buildTierScaffold(5); // TDZ here
The fix is structural, not a workaround. The override loop must live AFTER the const declarations:
export const TIER_5_MILESTONES = buildTierScaffold(5);
export const TIER_6_MILESTONES = buildTierScaffold(6);
// ... T7, T8, T9, T10 ...
// Now safe — all TIER_N_MILESTONES consts are initialized:
for (const tierList of [TIER_5_MILESTONES, TIER_6_MILESTONES, TIER_7_MILESTONES, TIER_8_MILESTONES]) {
for (const ms of tierList) {
if (ms.atWave === 1000) { /* add gemMarketUpgrade effect */ }
}
}
Why rule 107’s “narrow patch context” doesn’t catch this: the patch tool inspects the surrounding context for matching old_string. The “logical home” for the override loop (right after the T4 wave-1000 override) is BEFORE the T5-T10 declarations — the patch lands there without complaint. The TDZ only surfaces at module load time, which the patch tool doesn’t simulate.
The diagnostic check (run before committing ANY module-load override loop):
# 1. List every const that the loop references:
grep -n "for.*of.*\[TIER_\|for.*in.*TIER_" src/milestones/MilestoneSystem.js
# 2. List every const declaration in the file:
grep -n "^export const" src/milestones/MilestoneSystem.js
# 3. Verify each referenced TIER_N_MILESTONES appears in the declaration
# list AFTER the loop's line number.
If the loop’s line number is greater than any referenced const’s line number, the patch is broken.
Defense-in-depth in the verify: any test that loads the affected module will throw on import. Run a smoke verify that imports MilestoneSystem and asserts the tier lists are non-empty:
const mod = await import('/src/milestones/MilestoneSystem.js');
assert('TIER_5_MILESTONES loaded', mod.TIER_5_MILESTONES.length > 0,
`TDZ would cause this to throw at import. got length=${mod.TIER_5_MILESTONES?.length}`);
The verify’s own import would fail before any test runs, which is the silent-no-op equivalent of rule 99’s “visible but inert” — the user sees a passing test run that never actually exercised the module.
Cross-feature applicability: any module that exports multiple consts and then iterates over them in module-load code (override loops, validation passes, registry registration). The same TDZ trap appears in any pattern like:
for (const def of [DEF_A, DEF_B, DEF_C]) validate(def);
export const DEF_A = { ... }; // TDZ
Caught by: the 2026-08-08 SZD-per-tier-unlock commit. First patch placed the T5-T8 override loop right after the T4 wave-1000 override (the “logical” location). The patch tool landed it cleanly. Manual re-read of the file post-patch revealed the loop was above the export const TIER_5_MILESTONES = buildTierScaffold(5) line — would have thrown ReferenceError at module load. Relocated the loop below all TIER_5/6/7/8_MILESTONES declarations. No runtime issue after relocation.
Mental shorthand: “Override loop references TIER_N_MILESTONES consts? Verify the loop’s line number is BELOW every referenced const’s declaration. const is not hoisted; loop placement above the const = TDZ at module load.”
[IMPORTANT: The user has invoked the “canvas-game-architecture” skill, indicating they want you to follow its instructions. The full skill content is loaded below.]
name: canvas-game-architecture description: “Canvas2D games: rAF + timestep + DPR + offline. Paused-state gating + run-side construct/persistent-init pairing + source-string assertions + per-creature layered sprite polish + button flash/ripple player-action feedback + threshold-celebration modal for irreversible events + progress-meter near-completion affordance (see references/).”
Canvas Game Architecture
Browser canvas games with per-frame spatial logic — combat, projectiles,
movement — sit outside the 16-system EventBus scaffold used by
idle-game-scaffold. This umbrella covers that class.
When this fits
- Gameplay is per-frame spatial (enemies move, projectiles fly, particles emit) rather than just a number-stack that ticks up
- Vanilla JS + Canvas2D, no Phaser/PixiJS/Kaboom (OG values the zero-build pipeline that keeps dev iteration under 5 seconds)
- Mobile-primary; iOS Safari cold-restart correctness matters
- Save layer needs an offline-progression story
When NOT this
- Narrative-idle / Cookie-Clicker / generator games → use
idle-game-scaffold. Generator/prestige math lives there. - Asset-pipeline integration into an existing game → use
creative/game-asset-integration. Icon-LoRA + Florence verification belongs there; this umbrella is the runtime architecture. - WebGL / shader-heavy games → out of scope.
The skeleton (7 files)
index.html Single canvas + module script tag
src/main.js Bootstrap order matters: save → state → offline-earnings → rAF
src/canvas.js DPR-aware resize, max DPR=2 (mobile perf budget)
src/state.js Single source of truth; all numeric fields
src/loop.js rAF + fixed-timestep accumulator + render
src/save.js localStorage + lastSeen + offline cap + cold-restart safety
src/hud.js DOM overlay (toggleable for graphics-only mode)
The order in main.js is load-bearing. Load save.js → loadState() →
applyOfflineEarnings() → loop.start(). A cold restart (iOS Safari
background-kill) re-runs the whole bootstrap; offline earnings must apply
before the first rendered frame or the player sees stale state for one
tick.
Five contracts to bake in
1. rAF + fixed-timestep accumulator (clamped)
const TICK_MS = 1000 / 60;
function frame(now) {
const dt = Math.min(now - lastTime, 250); // clamp huge gaps (tab bg'd)
lastTime = now;
accumulator += dt;
while (accumulator >= TICK_MS) {
tick(state, TICK_MS); // deterministic 60Hz
accumulator -= TICK_MS;
}
render(ctx, state); // once per rAF
requestAnimationFrame(frame);
}
Combat math needs determinism (DPS at 250ms instead of 16.6ms lets an enemy survive 15× more damage on a slow frame). The 250ms clamp prevents the tick loop from running 600× to “catch up” on resume from a backgrounded mobile tab. Decoupling tick from render means the game logic stays 60Hz even on a 120Hz mobile display.
2. DPR-aware canvas sizing, capped at 2
Math.min(devicePixelRatio, 2). iPhones report DPR=3 (4-9× pixel work per
frame); a tower defense with ~200 sprites will tank the framerate without
the cap. Visual difference is imperceptible; perf improvement is 56-78%.
Use setTransform(dpr, 0, 0, dpr, 0, 0) rather than scale() — scale
composites across existing transforms, setTransform resets first.
3. Save with lastSeen + offline cap
Per-archetype offline cap (24h hard cap is the canonical default — see
references/canvas-game-architecture.md for the cap-model trade-off
table). applyOfflineEarnings() runs in bootstrap() before
loop.start(). On visibilitychange only update lastSeen; the actual
recompute happens on the next bootstrap if the OS cold-killed the tab.
4. Per-archetype data, not engine branches
For combat, every enemy type has different damage behavior (contact-only, contact-DPS, hybrid explode+aura, ranged with projectiles). The wrong architecture is a switch statement in the engine; the right architecture is data:
EnemyDamageProfile {
contact: boolean
contactDPS: number
hybridExplode: number
hybridAura: number
auraDuration: number
ranged: boolean
projectileDamage: number
}
New enemy types = new profile entries; the engine iterates fields
uniformly. Same pattern for milestones ({ atWave, effects[] }), weapons,
upgrades. Adding a new enemy type later must NOT require editing the
engine.
5. Milestones are data
const MILESTONES = [
{ atWave: 5, effects: [{ type: 'spireHeight', delta: 1 }] },
{ atWave: 10, effects: [{ type: 'autobuySlots', delta: 1 },
{ type: 'shopUnlock', category: 'ranged' }] },
];
The tick checks currentWave against the milestone list and fires
effects. New milestone types = new effects[].type handler in the
dispatcher (one switch, rarely extended). Most new milestones just add
entries to the list.
Design-elicitation workflow for new game specs
When OG opens a new game project, this is the playbook that worked for Spire-Defense (2026-07-24):
- Restate the spec verbatim in your own words, organized by section (core loop, resources, shop, progression, HUD). Forces high-level verification before answering details.
- Ask 2-4 load-bearing questions per turn. Not polish. The question “how does each enemy damage the beacon?” is architecture. The question “what color is the spire?” is polish — don’t ask yet.
- Flag the rewrite-later risks early. Save format, damage contracts, milestone schemas, prestige reset shape — easy on day 1, expensive on day 30. Plant them as commitments before scaffolding.
- Scaffold with TBD numbers, then request numbers per subsystem. The skeleton runs without any gameplay numbers. Land it, then ask for first-wave stats, first weapon, first milestone, one subsystem at a time. Keeps the design discussion tractable.
- Don’t auto-import active-project conventions. A generic ask like “research X” is scope-free until the user names a project. Wait for the user to signal a connection to dark-spire or idle-scaffold before reusing their patterns.
What carries over from idle-game-scaffold
Even for combat, two things carry over unchanged:
- Save manager — localStorage + autosave race protection
- Achievements / Synergies (when they appear) — pattern-matches the existing reference, orthogonal to whether the underlying loop is combat or generators
What does NOT carry over:
- Generators / Golden cookies / IAP / multi-layer prestige (combat games don’t have those patterns in the same shape)
- EventBus (combat has per-frame spatial state that benefits from a
single
stateobject, not events)
Tailscale serve convention
OG’s network rule: serve on 100.113.161.103:PORT, hand the user one
URL per interaction, never localhost. For canvas games with no build
step:
cd /home/og/idle-games/<game>
scripts/dev-server.sh # auto-pick port, kill prior listener on it
scripts/dev-server.sh 8775 # explicit port
# User opens http://100.113.161.103:8775/index.html?v=<commit-sha7> on phone
scripts/dev-server.sh (in this skill) handles the python http.server
lifecycle + auto-port-picking + cache-buster URL slug so the prior
session’s listener can’t accidentally serve stale state from the
wrong directory. WebRTC (LiveKit, etc.) needs tailscale serve https / for certs; plain HTTP idle/canvas games only need the IP.
Cache-buster URL convention
OG’s rule (Spire-Defense 2026-07-25, after the Browser-caching fiasco):
always include ?v=<commit-hash> on every URL sent to the user.
Format: http://100.113.161.103:<port>/index.html?v=a8f0d65. The
hash is the commit HEAD when the link was sent. Bump it on every push.
Why: browser_console and the user’s iPhone Safari both cache .js
files aggressively. Without the cache-buster, a player sees the old
build because the browser serves the cached .js even after the
forge updates. The ?v= query string tricks the browser into
thinking it’s a fresh URL. Bumping the hash after each push forces
a reload of the new code.
Triple-combo when a “fix” doesn’t appear to apply: (1) confirm
the server is serving the new file via curl <URL>/src/<file>.js,
(2) make sure the user’s URL has the new ?v= hash, (3) have the
user open DevTools → Network → “Disable cache” checkbox. If still
broken, the fix is wrong, not the cache. Spire-Defense 2026-07-25
hit this twice (black-screen fix e79d9d9 was invisible until
port-restart + new hash, then shop-card fix 2da222e got the same
treatment).
Verified projects using this scaffold
- Spire-Defense (2026-07-24 → 2026-07-25) — vanilla canvas top-down TD,
100.113.161.103 via static HTTP, rAF + fixed-timestep + 24h offline
cap, per-archetype damage contract, milestone-driven spire growth +
autobuy slot unlocks. Repo:
git.hl.c4.io/og_arist0tle/Spire-Defense.git. Bootstrap commits:173e535(infra lift),3adc3bf(rewire + stub deletion),31342f7(game-specific skeleton). Subsequent commits cover play-loop fixes, Fibonacci spiral lanterns, soft/hard reset split, kill-on-touch enemies, mobile-anim decoupling (setInterval), and milestone schema. - Dark Mine Runner (2026-08-16) — vanilla canvas runner, descent +
drill + lantern-orbiting combat + post-stratum offer overlay. Same
scaffold family as Spire-Defense:
Stratum/Cart/RunLoopparallel to Spire’sWaveManager/Beacon/GameLoop. Hit the same silent-overlay failure mode twice on the same day (once for an overlay Promise throw, once for a missinggame.startRun()call) — both surfaced through the diagnostic checklist inreferences/async-overlay-from-raf-silent-failure.md. Repo:/home/og/Dark-mine-runner/.
B-shape milestone pacing (project-level tuning)
Per idle-game-design-canon‘s pacing rules and OG’s spec correction
on Spire-Defense (2026-07-25): when a TD / wave-based idle uses
milestones, ship the B-shape cadence pattern, not literal Fib:
- Wave 1, 5, 10, 25, 50, 100, 250, 500 are cadence — every session gets visible progression
- Wave 1000, 1597 (Fib 16/17), 2584 (Fib 18), 4181, 5000 are the Fib tail — long-arc markers
- Cap at 5000 for Tier 1 — don’t ship milestones beyond that until Tier 2 is balanced
- No autobuy-slot unlocks until Tier 2 milestones. Bulk-purchase automation is a late-game convenience; unlocking it early trivialises the player’s per-purchase deliberative loop which is the main interaction surface
Effect types in the schema (data layer first, handlers later):
| Effect | Behavior |
|---|---|
spireHeight |
Increment visible tier counter; tower physically grows |
shopUnlock:itemId |
Reveal a previously-hidden: true catalog entry |
milestoneToast:text |
Brief in-game announcement; auto-dismiss |
tierPreview:tier |
HUD displays “Tier N ahead” hint |
Shop gating implementation: items use hidden: true + requiresWave: N
in catalog, Shop.js filters on render. Card-level DOM swap (not greyed-
out) — no false affordance.
Light-themed weapons inside the player zone (Spire-Defense, 2026-07-25)
Per OG: every milestone-progression weapon has to be light-themed and stay inside the safe zone. New weapons per milestone:
| Milestone | Weapon | Effect axes |
|---|---|---|
| Wave 5 | Oil Field | count, size, duration, damage |
| Wave 25 | Beacon Pulse | damage, cooldown (always radius = halo) |
| Wave 100 | Lantern Aura | multi-target count + HP regen |
Critical constraint: every deploy / AOE shape reads from
Beacon.getMaxHaloRadius() (or equivalent), never a local constant.
This means a tier-bonus that expands the halo naturally scales all
weapons proportionally — no extra upgrade axis needed. See the
“Range-limited systems pull their reach from a shared geometry”
section above.
Practical bootstrap workflow (spire-defense, 2026-07-24)
The 3-commit pattern that proved bisect-safe:
-
Lift infrastructure, no game logic. Pull
vendor/break_infinity.mjs,src/utils/Decimal.js,src/core/{EventBus,GameState,GameLoop}.js,src/offline/OfflineSimulator.js,src/prestige/PrestigeLayer.js,src/automation/AutoBuyManager.jsfromidle-scaffold. ExtendEVenum with game-specific events. ReshapeGameState._freshState()for this game’s resources. Don’t touch main.js yet — file lift is its own commit. -
Rewire main.js, delete stubs. Replace
src/main.jswith minimal bootstrap that imports the new modules. Inline trivial save helpers (loadFromStorage/persistToStorage) as a stopgap until the realSaveManagerships in commit 3. In the same commit, delete the now-supersededsrc/state.js/src/loop.js/src/save.js/src/hud.js. Atomic: a partial failure (one file’s import breaks) doesn’t strand dead stubs in the tree. -
Game-specific skeleton. Land subsystems under
src/<domain>/, plus the fullSaveManager,Renderer,HUD, and any per-domain archetype registries (src/enemies/EnemyArchetype.js-style). Commit message MUST say “Still TODO in next commit: <list>” so future agents cloning at this commit know what’s stubbed vs load-bearing.
Schema-first milestone rollout (Spire-Defense, 2026-07-25)
When a milestone system has multiple effect types (some wired, some not), land the data layer first, wire handlers in a follow-up commit. The HUD-side display of “Next: <wave> (<label>)” works purely off data + bus events — no engine hook needed. This lets the player see the upcoming milestones and lets you verify the cadence shape lands right before writing effect handlers.
Verified on commit d7451cf: shipped TIER_1_MILESTONES data +
MilestoneSystem listener + HUD display + a placeholder
{ type: 'shopUnlock', itemId: 'lantern_aura' } effect (item in
catalog, hidden until wave 100). Effect handlers land in commit 2.
This pattern avoids the “milestone fires but nothing visibly changes”
trap when only some effects are wired.
Beacon HP + migration on balance rebalance
When you lower beaconMaxHP after players have saved games with the
old value, your deserialize() must migrate the saved blob, not just
fall back to default. Spire-Defense d7451cf shipped this pattern:
gs.state.beaconHP = new Decimal(raw.beaconHP ?? 500);
gs.state.beaconMaxHP = new Decimal(raw.beaconMaxHP ?? 500);
if (gs.state.beaconMaxHP.gt(500)) {
// Old save had beaconMaxHP = 10000. Migrate proportionally.
const ratio = gs.state.beaconHP.div(gs.state.beaconMaxHP);
gs.state.beaconMaxHP = new Decimal(500);
gs.state.beaconHP = new Decimal(500).times(ratio);
}
Without the migration, players with old saves load with mismatched HP values and your “balance change” looks broken. Same migration pattern applies to ANY field that ties to other fields (HP vs maxHP, level vs XP threshold, currency caps, etc.).
Also use this with care: Reset Save button + clear localStorage +
port-restart needed when rebalancing reshape the state shape itself.
A renamable field can be defaulted; a renamed or restructured field
needs shape-aware migration.
Lift vs. write-fresh — discipline check
Lift freely (scale across games): Decimal, EventBus, GameState persistence pattern, GameLoop rAF pattern, OfflineSimulator (config-tweaked), PrestigeLayer base class, AutoBuyManager pattern.
Read for shape, never for content: any specific game’s generators / upgrades / achievements / synergies / visuals. The data tables are content, not architecture.
Always write fresh: per-game subsystems (weapons, enemies, buildings), data tables for archetypes/items/milestones, all cosmetic/visual choices.
If you’re writing if (type === 'boss') in engine code, that logic
belongs in a data table — not the engine.
Canvas pixel probe — verify visual without screenshots
This is the single highest-value check before shipping a canvas game. HTTP 200 + zero console errors + window.__spire exposed does NOT prove the canvas paints what you think it paints. Dark Mine Runner v1 (2026-08-16) shipped with the cart pinned to the left edge of the canvas, lantern halo off-screen, and a 3-second auto-scroll — all passing 58 unit tests because every test checked numbers in isolation, not the rendered output. A 30-second pixel probe of the canvas would have caught all of them. Make this check load-bearing in your build protocol: before declaring done, do one browser_navigate + one canvas pixel probe + confirm the cart is at viewport center.
(() => {
const c = document.getElementById('game');
const s = document.createElement('canvas');
s.width = c.width; s.height = c.height;
s.getContext('2d').drawImage(c, 0, 0);
const cx = Math.floor(c.width/2), cy = Math.floor(c.height/2);
const px = (x,y) => Array.from(s.getContext('2d').getImageData(x,y,1,1).data);
return JSON.stringify({
center: px(cx, cy), // expected: warm halo
halo100: px(cx+100, cy), // expected: intermediate gradient
edge: px(cx+400, cy), // expected: base background
});
})()
Run via browser_console expression=. Verify gradient by inspection:
halo pixels should fall between center and edge colors.
Extension — pixel-sample around a forced-spawned enemy (for aura-shape archetypes): the static pixel probe above checks ambient state, but to verify that a specific enemy is drawing at the right place, force-spawn one at a known position then sample that exact region. The non-bg pixel count in the bounding box is your “is it drawing at all” probe:
wm._spawnEnemy('shade', { spawnPos: { x: 195, y: 200 } });
// ...wait a frame...
const data = ctx.getImageData(150, 150, 90, 100);
let nonBg = 0;
for (let i = 0; i < data.data.length; i += 4) {
const r = data.data[i], g = data.data[i+1], b = data.data[i+2];
if (r > 40 || g > 40 || b > 40) nonBg++;
}
return { nonBgPixels: nonBg }; // 0 = invisible, >0 = rendering
Combine with the geometry-count verify (next pitfall) for cheap-before-
expensive diagnosis. See references/aura-shape-visual-iteration.md for
the full 4-round pattern.
Geometry verification via pathForShape command-counting
When shipping a new pathForShape shape (star, diamond, umbrella,
web, custom), the cheapest-before-expensive gate is to spy on the
canvas commands emitted. Don’t rely on pixel sampling or screenshot
review alone — those catch “is it drawing” but not “is it drawing the
right shape”.
const calls = [];
const origMove = ctx.moveTo.bind(ctx);
const origLine = ctx.lineTo.bind(ctx);
const origArc = ctx.arc.bind(ctx);
const origQuad = ctx.quadraticCurveTo.bind(ctx);
ctx.moveTo = function(...a) { calls.push(['moveTo', a]); return origMove(...a); };
ctx.lineTo = function(...a) { calls.push(['lineTo', a]); return origLine(...a); };
ctx.arc = function(...a) { calls.push(['arc', a]); return origArc(...a); };
ctx.quadraticCurveTo = function(...a) { calls.push(['quad', a]); return origQuad(...a); };
ctx.beginPath();
pathForShape(ctx, 'web', 30, 30, 25);
Then assert on the counts:
lineTo === 6→ 6 spokes (radial pattern)arc === 0→ no ringsquad === 6→ 6 outer-edge quad curves (concave pattern)
This catches “I added 5 rings but the verify didn’t catch it” class bugs BEFORE the visual review step. ALWAYS include a geometry-count assert in the verify-script for any new shape branch.
Anti-pattern: require_() shim for circular imports
When two modules both need Decimal and live in folders that might cycle, the temptation is to add a runtime shim like:
function require_() { return Decimal; }
This “works” but breaks tree-shaking, depends on window.__spire being
initialized, and hides the real import edge. Just do the static
import { Decimal } from "../utils/Decimal.js" at the top of the file.
If a real cycle exists, fix it structurally — extract the shared leaf
into its own module — not with a runtime hack.
Range-limited systems pull their reach from a shared geometry
When the player’s safe zone (halo, light radius, tower range, base defense perimeter) gates both visuals AND weapon reach, the weapons should pull their deploy/cast radius from the same source instead of owning their own constant. Per OG (Spire-Defense 2026-07-25): “The lanterns can overlap their ranges and stay closer together…spire weapons should be contained within the light radius.”
Implementation: a getMaxHaloRadius() or equivalent max-reach
accessor on the beacon/zone class, separate from the live
getHaloRadius() that scales with HP. Weapons call getMax*() to
size their effect. The halo can grow (per-tier bonus for example)
and the weapon reach grows with it for free, no extra upgrade or
rebalance needed.
class Beacon {
getMaxHaloRadius() {
const tier = this.state.state.spireHeight || 0;
return this.opts.maxHaloRadius + tier * this.opts.tierRadiusBonus;
}
}
class OilGrenade {
// Deploy at 85% of halo edge, never outside.
_deploy(spireX, spireY) {
const r = this.beacon.getMaxHaloRadius() * 0.85;
}
}
The constraint lives in ONE place (the beacon’s max-radius calc). Visual halos, deploy radii, AOE shapes, projectile max range, even sound-falloff distances all stay aligned automatically. Reverse the direction (weapons owning their own reach, not pulled from geometry) and every balance change requires editing every system.
Milestone data layer — tier-specific keys for cross-tier isolation
Per OG (Spire-Defense 2026-07-26): “if tier 1 wave 25 milestone unlocks beacon pulse for purchase in the shop, tier 2 wave 25 may have a different shop unlock.”
Use the tier:atWave key format in state.unlockedMilestones so
each tier’s milestones are isolated:
// MilestoneSystem._onWaveAdvance:
const tier = this.state.getCurrentTier();
const list = MILESTONES_BY_TIER[tier] || [];
for (const m of list) {
const key = `${tier}:${m.atWave}`; // "1:25", "2:25", etc.
if (to >= m.atWave && !this.firedOnce.has(key)) {
// ... push key to state.unlockedMilestones ...
}
}
// Weapon.isUnlocked:
isUnlocked() {
const key = `${this.tier}:${this.unlockAtWave}`;
const list = this.state.state.unlockedMilestones ?? [];
return list.includes(key);
}
A tier-1 weapon at unlockAtWave: 25 checks key "1:25". A tier-2
weapon at the same unlockAtWave: 25 checks "2:25". Crossing
wave 25 in tier 2 does NOT unlock tier-1 weapons. The contract
is documented in src/milestones/MilestoneSystem.js and
generalizes to any tier-specific content gating.
The two-stage unlock contract (one-time milestone, then purchase-locked use) is:
- Milestone gate (one-time):
Weapon.isUnlocked()checksstate.unlockedMilestones.includes('${tier}:${unlockAtWave}'). Once that key is in the array, it stays forever. Survives soft resets, page reloads, tier changes. - Purchase gate (one-time):
Weapon.purchased = truestays in memory across runs. - Active:
purchased AND unlocked— both must be true.
After both gates pass, the weapon is permanently usable in every run at every wave, regardless of current wave or HP. Verified on Spire-Defense 2026-07-26.
Pitfalls
-
Per-creature layered sprite polish lane. When a canvas game has a creature catalog with one
_drawXxx(ctx, sx, sy, c)method per id and the goal is to make each one read as alive rather than a flat shape, apply the 5-layer composition (halo + outline + body + life-signs + accents) with comment-anchored labels + regex source-asserts. Captured from the DMR v3.2-v3.3 autonomous build loop, six consecutive overhauls, zero regressions. Full pattern:references/per-creature-layered-sprite-polish.md. -
Paused-state must gate every tick path that mutates persistent state, not just the obvious one. When the game has a “paused” / “ended” / “stopped” state (Beacon Out screen, level-end transition, boss-defeat cinematic, level-up modal), the most obvious tick path is gated (e.g.
WaveManager.update()checksrunEnded) but side-channel tick paths bypass the gate because they’re independent. Common offenders: (a) the main rAF loop’s per-system update call (BeaconPulse / SignalFlare / ChainLightning / etc.), (b)setIntervaltickers (Lantern’s collision check at 33ms is the canonical example), (c) bus event subscribers that fire from a tick source. The result: resources accumulate while the player sits at the pause screen, and the player sees “X is increasing that shouldn’t be.” The fix is a multi-file audit: grep every side-effect tick path, gate each one, verify the gate is positioned BEFORE the side-effect (a gate after the side-effect is no gate). Spire-Defense 2026-08-05 had this exact bug —WaveManager.updatewas gated, but the main loop’s weapon update + Lantern’s setInterval kept firing, crediting oil + gems for kills on leftover enemies from the wave that just ended while the player sat at Beacon Out. Seereferences/paused-state-tick-gating.mdfor the button.“ The order is: browser firesbeforeunloadBEFORE the page actually unloads → if the unload handler persists state, it re-writes the OLD in-memory state to localStorage BEFORE the bootstrap runs, defeating the reset. The fix is to not have both: rely on autosave + a one-shot manual save (e.g. on Begin New Run / End Run / milestone), never the unload hook. Verified on Spire-Defense 2026-07-25 — OG was stuck because Reset Save appeared to do nothing; the diagnosis was abeforeunloadlistener re-persistingspireHeight: 11from the broken testing session right before unload. Fix committed as9bac72c. If both behaviors are needed, sequence them so the Reset Save handler runs first, sets a flag the unload reads, and the unload only persists if the flag isn’t set. -
const X = ...withoutexportwill silently resolveundefinedinimport { X }consumers. ES module imports of non-existent named exports don’t throw — they just becomeundefined. In Spire-Defense 2026-07-25,SAVE_VERSIONwas declared asconst SAVE_VERSION = 2;(noexport), then SaveManager.load()’ imported{ SAVE_VERSION }and the version checksavedVersion !== SAVE_VERSIONalways evaluatedX !== undefined, which made the check always pass and the save always get wiped + re-persisted in the same tick. The bug appeared as “save keeps coming back with stale data even after Reset Save.” When designing a SaveManager-style module with a version constant, ALWAYS useexport const SAVE_VERSION = N;. Ifimport { X } from "..."resolves toundefinedand the comparison still produces the wrong result, you’ve found it — addconsole.log(X, 'from', import.meta.url)for confirmation. -
TICK-driven
body.innerHTML = ''rebuild destroys click-bound elements mid-tap. The Shop.js anti-pattern that bit Spire-Defense 2026-07-26:bus.on(EV.TICK, refresh); // every 50ms function refresh() { // ... body.innerHTML = ''; // DESTROYS all cards // ... re-create all cards ... }The user’s tap is queued. On the next tick (within 50ms), the card element is destroyed. The click handler was bound to the old element; when the click event reaches it, the element is gone and the click is lost. The dev tools didn’t catch this because the test ran fast enough that the card wasn’t destroyed before the click fired.
Fix pattern: track each card by a stable key, create once, update inner content in place:
const renderedCards = new Map(); // key -> { card, type, ref } function refresh() { // ... structure reconciliation ... for (const w of weapons.shoppable()) { const key = `w:${w.shopId ?? w.constructor.name}`; let entry = renderedCards.get(key); if (!entry) { const card = makeCard(key, () => buyWeapon(w)); grid.appendChild(card); entry = { card, type: 'weapon', ref: w }; renderedCards.set(key, entry); } updateWeaponCard(entry.card, w); // in-place content update } }General principle: DOM elements that take user input must persist across refreshes. The audit rule for any UI module that uses
body.innerHTML = '': does any element have a click listener? If yes, the element must persist across TICKs. Track via aMap<key, element>, mutatetextContent/styleinstead of recreating. -
When OG says “describe the code flow” before patching, describe the flow FIRST, then propose a fix. Verbatim OG (Spire-Defense 2026-07-26): “Describe the code flow to me.” The trap is to start fixing before explaining, especially when multiple code paths look suspicious. The right response:\n 1. Read the relevant files end-to-end.\n 2. Walk the execution order in plain prose: which event fires first, which handler mutates which state, which subsequent reads use the new value.\n 3. Identify the specific point where the bug originates vs where it manifests (those are often different lines).\n 4. State what you suspect.\n 5. STOP. Wait for OG to confirm or redirect before patching.\n\n If you skip step 1-3 and go straight to “I’ll fix it by\n changing X”, you risk applying a fix to the wrong code\n path while the real one stays broken. The flow description\n often surfaces that the bug isn’t where you thought it\n was —
OG: "Look at the code where these problems started\n and fix it. Look at the old code from before the milestones\n and dev button additions and diff between that code and the\n current code". The fix is the flow description, not the\n patch.\n\n- iOS Safari caches module imports separately from the document.\npython -m http.serverdoesn’t sendCache-Controlheaders.\n Meta tags inindex.html(Cache-Control,Pragma,\nExpires) work for the document HTML, but Safari caches\n./src/main.js→./src/ui/Shop.jsetc. as separate\n fetches that the meta tags don’t reach. Diagnostic signature:\n the same URL shows the latest code in Discord’s in-app\n browser but stale code in Safari. Fix at the HTTP layer:\n a custom static server that sendsCache-Control: no-store\n on every response.\n\n The complete iOS-Safari-cache-defeat pattern:\n 1.server.pywithThreadingMixIn+no-storeheaders\n +allow_reuse_address+ 30s socket timeout. Replaces\npython -m http.server.\n 2.?v=<commit-sha>in every URL sent to OG.\n 3. Meta tags inindex.htmlas a fallback.\n 4. Areset-pendingsentinel written by the Reset Save\n button so iOS Safari back-forward cache doesn’t restore\n the OLD in-memory state after a wipe.\n\n Each defends a different cache layer. Skip any one and\n Safari can serve stale code/state. Theserver.pypattern\n is the only one that defeats module-level caching — meta\n tags +?v=query strings alone aren’t sufficient.\n\n- Single-threaded HTTP server hangs when one client is slow.\n The defaultpython -m http.serverand aTCPServerwithout\nThreadingMixInserialize all requests on a single thread.\n A slow client holding a connection stalls every subsequent\n request. Diagnostic signature: all requests time out, even\n from a fresh browser. Fix:class\n ReusableThreadedServer(ThreadingMixIn, TCPServer):plus a\n 30sconnection.settimeout(30)per connection. Without\nallow_reuse_address = Trueon theTCPServersubclass,\n restarting after a kill fails withAddress already in use\n due toTIME_WAIT.\n\n- Per-player saves via${STORAGE_KEY}.${sanitizedName}.\n When a canvas game needs multiple player identities (e.g.\n family device, shared PC), the save system needs three things:\n 1. AsanitizePlayerName()helper that converts input to a\n filesystem-safe slug (Unicode letters/digits preserved,\n everything else →_, leading/trailing punctuation stripped,\n 64-char cap).\n 2. Per-player keys derived from the base storage key:\n${STORAGE_KEY}.${safe}for named players,${STORAGE_KEY}\n itself for anonymous (shared slot).\n 3. A player index key (${INDEX_KEY}) storing a JSON array\n of known names so the picker can show existing players on\n next launch without scanning every per-player key.\n\n The picker is a full-screen modal that resolves to\n{ name, isNew }(name is null for anonymous).main.js\n blocks bootstrap on the picker resolution, then constructs\n the realSaveManagerwith the chosen player. A temp\nSaveManageris constructed first just to call\nlistPlayers()to populate the picker.\n\n Reset Save callsSaveManager.wipe()which removes only\n the current player’s slot + index entry. NOT a global\n localStorage nuke — that would destroy other players’ saves.\n\n Ported from idle-scaffold/dark-spire. The pattern is the\n same across canvas games; the only thing that changes is\n the storage key prefix.\n\n- Storage key bumping is the canvas-game migration mechanism.\n When the save scheme changes meaningfully (per-player adds,\n new field added, version upgrade), bump the storage key\n version (v4→v5). Old saves are silently ignored on\n next load — no migration code needed. This is a tradeoff:\n OG loses any in-progress save when the scheme changes. For\n canvas games where the player can wipe-and-restart, the\n key bump is cheaper than writing migration code. For\n long-running online saves, migration is mandatory. The\n bump is the right default for the canvas-game pace.\n\n- When two classes share a reference to the same object, use the same field name. A subtle bug pattern:SaveManagerholds the GameState asthis.gameState;PrestigeManagerholds it asthis.state. Both are passed the same instance frommain.js. A wipe path that usesthis.stateonSaveManager(where the field doesn’t exist) silently returnsundefined, thenew (undefined.constructor)(...)throws aTypeError, and the wipe doesn’t run. The error happens inside a DOM click handler so it doesn’t surface in console — the modal just stays up with no obvious cause. Audit rule: when two classes both need a reference to the GameState, use the same field name (this.state) in both. Don’t have one saythis.stateand the otherthis.gameState— pick one. The fix: renamethis.gameStatetothis.stateinSaveManagerso the field name matchesPrestigeManager. -
Stop blaming the platform when OG says your code is wrong. Spire-Defense 2026-07-26: OG reported shop clicks not registering. The assistant started by suggesting it might be an iOS Safari quirk with
position: stickycreating a weird stacking context. OG cut it off: "Stop blaming Safari for your poor code planning and review." Reading the actual code surfaced the destroy-and-rebuild bug above.When OG pushes back on a “blame the platform” explanation, immediately pivot to reviewing your own code. The pushback is almost always correct — the assistant reached for an external cause instead of finding the bug in code it just wrote. Default to “look at the code first, platform last.”
The pattern recurs: the same day had a SECOND instance (Profile switch bug — same reflex, different code path). The default-flip happens after the second occurrence. If the same pattern happens a third time, that’s a memory signal for the user (this assistant has a “blame the platform” reflex that needs explicit override).
-
OG’s “iterate until working” workflow vs the one-change-at-a-time rhythm. Two distinct protocols in the same session:
- Clean testing: OG says "I’ll restart the save and test when ready" → ship ONE change, STOP, wait for OG to test and signal the next move ("next chance", "toasts are good, wire X").
- Iterate to working: OG says "Couldn’t we fix this by
updating the save after each time an upgrade is bought?"
followed by "Iterate until you have a working system."
The session protocol changes:
- Don’t ask for confirmation between attempts. Each “still broken” report is a green light to try the next fix.
- Don’t restate the plan first. Just commit + push
- test URL. The user’s directive already approved the iteration loop.
- Stack candidate fixes, ship, test, ship, test. Each commit is small, but the cadence is tighter.
- STOP only when the user reports it works or explicitly tells you to stop. "Iterate" means until success, not until the assistant is out of ideas.
- How to tell which protocol is in effect:
- One-change-at-a-time: triggered by "make only one change and we will test", "next chance", fresh URLs.
- Iterate: triggered by "iterate until working", "doesn’t work yet, take another shot", "run a few tests using my method".
- Don’t mix the two. If you ask "should I try fix A or fix B?" during an iterate-until-working session, you’re slowing the loop. Pick A, ship it, wait for the user to report.
-
For profile-switching canvas games, persist on every state mutation — don’t rely on autosave alone. Per OG (Spire-Defense 2026-07-26): "Couldn’t we fix this by updating the save after each time an upgrade is bought?" — the user is right, and this is the right default for any save system with profile switching or any “switch to a different in-memory state” operation.
Three layers in order of priority:
- Persist on every WEAPON_BOUGHT / STATE change — localStorage writes are sub-ms for small saves. The extra frequency is negligible. The save is always current. Profile switches + save loads always get the latest data. This is the right default.
- Autosave on a timer as a backstop for state changes
that don’t go through a known event (e.g. raw
state.state.X = Ymutations). beforeunloadhandler as a last-resort flush. But this conflicts with the Reset Save button (the unload listener re-persists old state right before the page unloads). Skip this if you have (1) and (2).
The DOM-element-persistence pitfall (TICK-driven
body.innerHTML = '') is the complement to this: even if you persist on every change, the in-memory cached state (Lantern’sthis.lanternsarray, for example) can be stale after a save load. Two options:- Listen for STATE_RESTORED and rebuild cached state. Works, but easy to forget when adding a new weapon.
- Persist on every change so the in-memory state IS the save. After a load, the in-memory state matches the save exactly. The cached arrays are still built at construction time, but they update on every relevant WEAPON_BOUGHT (which is now persisted immediately). The first WEAPON_BOUGHT after construction matches the persisted count, so the cache is correct from the start.
Pattern: persist on every state-changing event + rebuild cached arrays on WEAPON_BOUGHT. The combination covers both “the save is current” and “the in-memory cache is current” without needing a STATE_RESTORED listener at all.
-
The shared “Begin New Run” pattern. When two UI paths converge on the same state transition (e.g. “Begin New Run” in a Beacon Out modal AND in a Shop panel header), factor a single helper that they both call:
// main.js — single source of truth for the action window.__spire_startNewRun = () => { // ... remove modals, close shop, re-arm beacon, clear // runEnded, emit newRunStarted ... };Both buttons call
window.__spire_startNewRun()instead of having separate click handlers. Don’t ship two divergent paths to the same state transition. The bug to watch for: one path does the wipe, the other doesn’t, and the player gets inconsistent state depending on which button they pressed.Alternative for systems with N entry points: emit a single bus event (
bus.emit('beginNewRun')) and have a single listener do the work. Pick one approach per system and stick with it — don’t mix helpers and events for the same action.\n converge on the same state transition (e.g. "Begin New Run"\n in a Beacon Out modal AND in a Shop panel header), factor a\n single helper that they both call:\n\njs\n // main.js — single source of truth for the action\n window.__spire_startNewRun = () => {\n // ... remove modals, close shop, re-arm beacon, clear\n // runEnded, emit newRunStarted ...\n };\n\n\n Both buttons callwindow.__spire_startNewRun()instead of\n having separate click handlers. Don’t ship two divergent\n paths to the same state transition. The bug to watch for:\n one path does the wipe, the other doesn’t, and the player\n gets inconsistent state depending on which button they\n pressed.\n\n Alternative for systems with N entry points: emit a single\n bus event (bus.emit('beginNewRun')) and have a single\n listener do the work. Pick one approach per system and\n stick with it — don’t mix helpers and events for the same\n action. Spire-Defense 2026-07-26: OG reported shop clicks not registering. The assistant started by suggesting it might be an iOS Safari quirk withposition: stickycreating a weird stacking context. OG cut it off: “Stop blaming Safari for your poor code planning and review.” Reading the actual code surfaced the destroy-and-rebuild bug above.When OG pushes back on a “blame the platform” explanation, immediately pivot to reviewing your own code. The pushback is almost always correct — the assistant reached for an external cause instead of finding the bug in code it just wrote. Default to “look at the code first, platform last.”
-
Defensive fixes can mask the real bug. When you find yourself writing fixes for edge cases that “could” happen but don’t fire in normal play, question whether the upstream bug is what you think it is. Defensive fixes can mask the real cause by making the symptom disappear in tested paths while leaving the underlying bug intact. Diagnostic signature: a defensive fix lands and the symptom improves on some test paths but not others. That’s the signal that the bug isn’t where you think it is.
-
Verify the user’s observation before defending the code. When OG reports a bug (“Light: 0% after beginning a new run”, “the spire grew but shouldn’t have”, “X happened that shouldn’t happen”), the first response is to verify their observation against the actual state, not to argue that the code is correct. In Spire-Defense 2026-07-26, OG reported “Light: 0% after beginning a new run.” The earlier fix (
bb0c3df) had correctly deferred_wipeRunState()past the Begin New Run click, and a quick mental trace suggested the new run should start at 100%. The assistant started writing “the backend is correct” without verifying. OG cut it off: “I’m not stupid, I’m better at this than you. Here’s a screenshot after my run died. Then a screenshot after I bought stuff and started a new one. The menu never went away and the Light% stayed at 0.” The screenshot revealed a different bug entirely (the soft-reset modal was still on screen, not the prestige modal — duplicate handler, not broken wipe).The trap is: when you’ve just shipped a fix for a bug, you have narrative pressure to defend that fix. The user’s next report might be the same bug in a different branch, OR it might be a completely different bug that happens to share a symptom. You can’t tell from the verbal report alone. The right move:
- Read the screenshot. Match the modal copy, the HUD values, the shop state against what you think the code should do.
- If the screenshot contradicts your model, trust the screenshot. The user’s observation is data; your model is a hypothesis.
- If your fix isn’t the cause, say so plainly. Don’t conflate the two — fixing the wrong bug is worse than no fix.
Verified on Spire-Defense 2026-07-26. The “defend the fix” response wasted a turn before OG provided the screenshot that revealed the actual bug. The screenshot-first response would have skipped that turn.
-
Two UI paths for the same logical flow diverged handlers and modals — emit the canonical event from both, route through one handler. Spire-Defense had two ways to end a run:
- Beacon reaches 0 HP →
state.damageBeaconemitsEV.BEACON_DEAD→PrestigeManager._onBeaconDeadruns → shows the “🕯 Beacon Out” modal with gem count. - Player taps End Run button → set
state.state.runEnded = true- emit a zero-damage
EV.BEACON_DAMAGE→RunControls‘smaybeShowSoftModalsawrunEnded=trueand showed a SEPARATE “🛒 Shop Phase” modal with different copy and different Begin-New-Run behavior (it didn’t call_wipeRunState(), so oil/upgrades/spireHeight weren’t reset when the player clicked Begin New Run).
- emit a zero-damage
The two paths looked similar to the player (“end run, shop, start again”) but had completely different internal state and copy. When OG reported the bug (“Light: 0% after beginning a new run, oil not wiped”), the modal on screen was the wrong one — Shop Phase, not Beacon Out. Diagnosis was a duplicate-handler problem, not a wipe-on-confirmation problem.
Fix (commit
f7e83c8): End Run button now emitsEV.BEACON_DEADdirectly. Both paths funnel throughPrestigeManager._onBeaconDead. Deleted the entire soft-reset modal code path (76 lines). One modal, one copy, one Begin New Run click handler.Pattern: when two UI actions lead to the same logical state transition (run end, prestige, save load, settings change), they should share the trigger event and the handler. If the user can trigger the same flow two ways and gets different modals/copy/ behavior, the architecture is wrong — there’s no design reason for the divergence. Find the canonical event and emit it from both entry points. Don’t ship “soft reset” and “hard reset” as separate end-of-run paths; pick one and route through it.
Diagnostic signature: a player says “I started a new run but state X wasn’t reset” or “I ended the run but Y happened differently than when I died.” That’s the duplicate-handler signature — same flow, different code, divergent state.
- Beacon reaches 0 HP →
-
Don’t drive visible animations off rAF on iOS Safari. rAF in the foreground runs at ~60fps on a healthy iPhone but drops to 1fps in low-power mode, when the device is warm-throttling, or when Safari is in battery-saver / low-power mode. Anything that needs to look continuously animated (lantern orbit, particle drift, propeller spin) becomes essentially invisible: at 1fps with orbit speed 2.5 rad/sec on a 50px-radius orbit, the lantern moves ~1.3px per visible frame. The user reports “lantern isn’t orbiting” — but the gameplay loop is fine (oil accumulates, waves advance). The diagnostic is precisely that mismatch: working numbers, frozen visuals.
Fix: decouple visible motion from rAF via an independent
setInterval(Nms)ticker dedicated to animation. Use ~30Hz (33ms interval) for visible motion; collision detection on the same ticker so hits don’t depend on render rate. rAF remains responsible for the actual canvas paint. Verified fix in Spire-Defense7cb1225: pulled orbit angle + collision out of theEV.TICKsubscription into asetInterval(() => _orbitTick(), 33)owned by the Lantern class, withattachToWorld({ getEnemies, getSpirePos })for cross-system wiring.Refinement: once the GameLoop uses the iOS-throttling 3-layer fix (
idle-game-scaffold/references/ios-timer-throttling-playbook.md), the bus emissions fromEV.TICKcontinue to fire — but rAF-styled render and downstream visual effects bound to a separate rAF loop are still throttled. The general rule: visible animation needs its own ticker, not the GameLoop’s. The Lantern class owns asetInterval(33ms)ticker for orbit angle updates. Even if the GameLoop’ssetInterval(200ms)runs fine, the orbit still freezes if its own update is bound to rAF. If the visual is supposed to move smoothly on a player device, find the rAF step in the path between state-change and canvas-paint and replace it withsetInterval.Related-but-different iOS issue (full timer pause on tab switch / screen lock) is handled by the 3-layer watchdog pattern documented in
idle-game-scaffold/references/ios-timer-throttling-playbook.md. Both classes of bug present with similar user-facing symptoms (“the X isn’t moving”) but have different fixes — don’t conflate them. -
Use the user’s workflow rule when scaffolding with missing design numbers. When OG asks you to “work through them in order and use your best guess with an explanation” (verbatim Spire-Defense 2026-07-24 instruction): (a) harvest every locked value from
DESIGN.mdfirst — many TBDs are actually resolved in adjacent sections, locked in commit messages, or obvious from values already shipped in the engine. Don’t ask OG to re-state what’s already in the repo. (b) when genuinely missing, make the guess explicit in the code (with aTBDcomment next to it), in the commit message (name the value + reason), AND in your chat reply (call it out so OG can correct without scanning the diff). (c) Prefer guesses that scale with the framework already in place (Fib-cost upgrades, sqrt-ish wave multipliers, golden-ratio layer expansions). (d) After guessing, ASK for the actual number in the next round — the guess is a placeholder, not a recommendation. (e) The workflow has a strict order: locked values → DESIGN.md sections already-decided → framework-fitting guesses → ASK for what you still need. Skipping the harvest step sends OG back to scroll-history and slows the loop. Captured from Spire-Defense bootstrap 2026-07-24, used across commitsc6641fe(gameplay loop), the lantern/orbit fix series, and the milestone schema commit. -
Don’t bootstrap order with offline-earnings AFTER rAF starts. A cold restart re-runs the whole bootstrap; if offline earnings run on rAF tick 1, the player sees stale state for one frame. Apply in bootstrap, before
loop.start(). -
Dev tools should be a separate DOM overlay that fires
dev:events on the existing EventBus, not a backdoor into the engine. When OG asks for a dev panel (wave step, speed multiplier, no-damage toggle, etc.), the architecture is:- New
src/ui/DevPanel.js(or similar) renders the panel UI — button + menu, plain DOM overlay matching the rest of the game. - Panel listens for state changes (current wave for label updates)
and emits
bus.emit('dev:<action>', payload)on click. - Subsystems that want to be dev-controllable subscribe to those
events. Speed multiplier lives in main.js as a module-local
let devSpeedMult = 1and is applied by scalingdtMsin the GameLoop’s onTick. No-damage lives in WaveManager asthis.devNoDamageand gates thedamageBeaconcall. - Wave-step uses
state.setWave(N), not direct mutation ofstate.state.currentWave. BypassingsetWave()skipsEV.WAVE_ADVANCE, which means MilestoneSystem doesn’t fire effects, shop gating doesn’t trigger, and HUD shows stale data. The dev tool gets the player to a wave, not the side effects of that wave. - Dev flags do NOT persist. Don’t add
devSpeedMultordevNoDamageto_freshState()or SaveManager. Module-local variables that reset on page reload — matches OG’s “dev tools are sandbox, not savegame” mental model.
Verified on Spire-Defense 2026-07-26 (commit
d11fe2a).Every visible time-driven subsystem needs the dev speed multiplier, not just GameLoop subscribers. Scaling
dtMsin the GameLoop’s onTick only reaches systems that subscribe to GameLoop ticks. A subsystem with its ownsetInterval,requestAnimationFrame, orperformance.now()loop (e.g. the Spire-Defense Lantern’ssetInterval(() => _orbitTick(), 33)for iOS-throttling-resistant animation) will run at real wall-clock cadence regardless of dev speed. Wire each such subsystem todev:speedindependently:// Any subsystem with its own ticker: this.devSpeedMult = 1; bus.on('dev:speed', ({ mult }) => { this.devSpeedMult = mult; }); // When applying dt to its visible state: const dtSec = ((deltaMs || 0) / 1000) * (this.devSpeedMult ?? 1);Before claiming the speed multiplier “works,” audit the codebase for independent tickers:
grep -rn "setInterval\| requestAnimationFrame\|performance.now" src/. Anything that ticks on its own needs the multiplier wired.Dev-tool speed multipliers need a slow baseline so the multiplier is visually readable. If the baseline animation is already fast (e.g. orbit speed 2.5 rad/sec — a full rotation every 2.5s), then 10x = a vibrating dot the eye can’t track. Pick a baseline slow enough to read as “slow sweep” at 1x (e.g. 0.5 rad/sec = 1 rotation / 12s); verify the multiplier at 5x reads as “noticeably faster” and at 10x as “fast sweep” — not “blur.” If the user reports “no visible difference” at 10x, the baseline is too fast, not the wiring wrong. Verified on Spire-Defense 2026-07-26 (commit
7635469): droppedBASE_ORBIT_SPEED_RAD_PER_SECfrom 2.5 to 0.5 after OG reported the multiplier wasn’t visible. Pattern generalizes to any dev-tool time scaling. - New
-
Save migration: pick ONE return shape from
SaveManager.load()and stick to it. DMR v3 shipped withSaveManager.load()returning aGameStateinstance directly, whilemain.jsaccessed it asloaded.state(which works because GameState has a.stateproperty). When v3.1 added cross-version migration, the natural reflex was to return{ state: migrated.state, migratedFrom: priorKey }— but this broke the consumer contract: a function that previously returned aGameStateinstance now returned a plain object. Tests assertingloaded.crystalsfailed becauseloadedno longer had a.stateproperty. Diagnostic signature: tests that check.state.Xwork, but tests that check.X(treating the loaded value as the data directly) break. Fix: always return the same shape regardless of which path fired. If the canonical shape is aGameStateinstance, set the migration marker as a free property on the instance (migrated._migratedFrom = priorKey; return migrated;) instead of wrapping in a fresh object. Caller code stays unchanged across v2 → v3 → v3.x saves. Verified on DMR 2026-08-17 (commit033856b). -
Timer accumulator
else if (!timer)initialization-only bug. When a per-tick accumulator is stored in a state object (c.buildingsTimers[kind]), the lazy-init patternelse if (!c.buildingsTimers?.__smelt) { c.buildingsTimers.__smelt = elapsed; }initializes the key on the FIRST tick (when undefined) but never updates the existing value on subsequent ticks. Result: the timer increments for one tick, stays at that value forever, and the feature fires once then silently stops. DMR v3.1 hit this on the trace-smelting research feature — the smelting fired once after 300s and never again. Diagnostic signature: a feature works the first time it’s gated by a timer, then never fires again. Fix pattern: always update the timer on every tick, regardless of whether the cycle fired. Two equivalent forms:const _smeltElapsed = (c.buildingsTimers.__smelt ?? 0) + dtSec; const _smeltCycles = Math.floor(_smeltElapsed / _smeltInterval); c.buildingsTimers.__smelt = _smeltElapsed - _smeltCycles * _smeltInterval; if (_smeltCycles > 0) { /* ... apply effect ... */ }Notice
c.buildingsTimers.__smeltis updated unconditionally, BEFORE the conditional. Compare to the broken form which only set the timer inside theelse ifbranch — that branch never fires after the first tick. Audit rule for any new timed game subsystem: grep forif (!this.<timer>)orelse if (!c.<timer>)and verify the timer mutation happens in BOTH branches (or, preferably, unconditionally outside the conditional). -
Build identity (BUILD_VERSION + BUILD_TAG + build chip in corner) for any browser game shipped via URL slug. When the URL has a
?v=<commit-sha>cache-buster (see “iOS Safari caches module imports” pitfall above), players have no way to verify what they were served. Addingsrc/build.jswith two constants —BUILD_VERSION = '<commit-sha>'andBUILD_TAG = 'v3.x'— and a small DOM chip rendered at boot is the cheapest verification path:const _chip = document.createElement('div'); _chip.id = 'dmr-build-chip'; _chip.textContent = `${BUILD_TAG} · ${BUILD_VERSION}`; document.body.appendChild(_chip);CSS:
position: fixed; bottom: 6px; right: 6px; opacity: 0.15;with:hover { opacity: 1 }. Low-opacity by default so it doesn’t clutter gameplay; revealed on hover when the player wants to verify what they’re seeing. Also exposewindow.__dmr.build = { version, tag }for diagnostic console work. Bump both constants in a single small commit per release —git rev-parse --short HEADthensed -i. Pattern generalizes to any browser game served on a Tailscale URL or local dev server: the player can never tell what build they have without a visible indicator. -
Don’t TRUST rAF for offline-resume correctness. On iOS Safari, the OS may cold-kill backgrounded tabs. The rAF loop, the listeners, and even the
lastSeengetter all disappear. The offline-earnings step must live at the bootstrap layer. -
Don’t forget the
Math.min(dpr, 2)cap. Full-DPR canvas on a DPR=3 iPhone is 9× pixel work; a tower defense with ~200 sprites will tank the framerate. Imperceptible visual loss; massive perf win. -
Don’t branch enemy types in the engine. New enemy types = new profile entries. The engine iterates the profile fields uniformly.
-
Don’t ask for polish before architecture. “What color is the spire?” is a wasted question; “How does each enemy damage the beacon?” is a contract question. Spend the design-budget on contracts first.
-
Subsystem base class + central registry for sharing cross-cutting dev hooks across N instances. When you have N instances of a similar subsystem (weapons, traps, projectiles, minions, towers), each needing to subscribe to the same
dev:events (speed, no-damage, future ones), DO NOT wire each subsystem independently to the bus. The duplication scales badly — each new weapon means anotherbus.on('dev:speed', ...)line in main.js.Instead: create an abstract base class with shared state (
this.devSpeedMult,this.devNoDamage) and abstract lifecycle hooks (update(dtSec),attachToWorld(...)), plus a central registry that broadcasts dev events to every registered instance. Verified on Spire-Defense 2026-07-26 (commit3cc3af2) with the Weapon / WeaponRegistry pair; the pattern generalizes to any N-instance subsystem.The benefit: a new weapon file needs ZERO changes to main.js’s dev hook wiring. Just
weapons.add(new XWeapon(...))and it’s automatically dev-controllable.Caveat: subsystems with independent tickers (e.g. Lantern’s
setInterval(33ms)for iOS-throttling-resistant animation) still need to readthis.devSpeedMultwhen applying their own dt. The registry sets it viasetDevSpeed()— the broadcast fires synchronously so the next independent tick reads the right value. -
When fixing a bug, look at the OLD code first if OG says “old behavior is what we want.” OG’s verbatim (Spire-Defense 2026-07-26): “I propose that you specifically look at the code you did to fix the end run and beacon death events. Look at the old code from before the milestones and dev button additions and diff between that code and the current code in the areas of end run and beacon death events. The old end run button code functionality is what we want. Replace any current code from end run/beacon death with the old end run code. Do not deviate from these instructions without consulting me.”
The directive structure:
- Specific historical anchor — “the old code from before X
additions.” Find that commit (
git log --oneline -- <file>,git show <sha>:<file>). - Specific diff target — narrow scope, don’t rewrite unrelated code.
- Desired outcome — “old behavior restored exactly.”
- Strict instruction-following preference with consultation
as the only escape hatch. “Do not deviate” means:
- ❌ No “while I’m at it” improvements.
- ❌ No defending the new code over the old.
- ❌ No parallel architecture for unrelated cleanup.
- ✅ Restore the old behavior exactly.
- ✅ Ask before adding any scope creep.
Workflow:
git log --oneline -- src/ui/RunControls.js # find anchor git show <old-sha>:src/ui/RunControls.js | head # view old code git diff <old-sha>..HEAD -- src/ui/RunControls.js src/prestige/PrestigeManager.jsThe new code should be as close to the old as possible while routing through the current modal ID / event bus. Don’t refactor the old flow’s internals; just wire it into the current architecture.
- Specific historical anchor — “the old code from before X
additions.” Find that commit (
-
Revert to last known good and bisect one change at a time when the user says you’re making it worse. If OG says “You obviously don’t know how to logically approach fixing the issue. You’re just making it worse. Revert to the last known good version before you pushed all the changes that broke things. Then make only one change and we will test.” (verbatim Spire-Defense 2026-07-26), the right move is:
-
git reset --hard <last-good-sha>to a known-good commit, force-push, and stop. Don’t keep patching on top of broken state. The user is telling you the broken-state hypothesis is wrong; staying on that branch compounds the damage. Verified on Spire-Defense 2026-07-26: after stacking SAVE_VERSION bumps, STORAGE_KEY bumps, areset-pendingsentinel, andrecomputeDerivedStateall on top of an underlying test-data corruption, the right move wasgit reset --hard 4892ecc(last visually-good commit) and start from there. -
After revert, ship ONE logical change per commit and stop for testing. “One change” means: one new module, OR one wiring of an existing module, OR one tuning constant — never a mix. The user bisects by
git logto find the commit that broke their build; if a commit lands 3 features, they can’t isolate which feature caused the regression. -
If a “fix” doesn’t apply, the bug is upstream — don’t stack more fixes on top. The stack-and-hope pattern (version bump → still broken → key rename → still broken → sentinel → still broken → derived-state rebuild → still broken) is how you go from one bad commit to ten. After ONE attempt fails, revert, don’t patch again.
-
Pick the smallest verifiable slice and ship it. “Add the spireHeight effect” sounds like a multi-file change but is actually: add
_applyEffects()to MilestoneSystem, plus one effect entry on the wave-5 milestone. ~20 lines. That commit was exactly the right size to test and accept. -
Audit the discard list before reverting.
git reset --hard <good-sha>discards ALL commits between current HEAD and the good SHA, including fixes unrelated to the broken feature. The Spire-Defense revert-to-4892ecccorrectly dropped the broken weapons-push series, but also discardedbb0c3df(the prior session’s correct oil-wipe fix). That bug reappeared in the next session and required re-fixing asf06208a. To avoid: rungit log --oneline <good-sha>..HEAD@{1}before the reset and cherry-pick any commits whose fixes are independent of the broken feature.
The session-level rule: if a fix doesn’t work on the second attempt, the model is wrong, not the implementation. Revert and rethink. Each safeguard feels cheap and additive, but each one widens the surface area for the next failure.
-
-
OG’s session protocol: one change at a time, stop and wait between sets. The two halves of the rhythm:
- Push a change → ship the URL → STOP. Don’t preemptively
queue the next change. OG’s verbatim signal phrases that mean
“I tested, this is good, what’s next?”:
- “Next chance”
- “Working. Next chance”
- “Toasts are good. Wire X” (X = the next single thing) Each phrase is the green light for the next single change.
- OG resets their own save between change-sets. OG’s verbatim phrase: “I’ll restart the save and test when ready.” This means: the next URL you ship will hit a fresh save state from OG’s device. Don’t carry over assumptions about what’s still in their localStorage from the previous test session. Treat each test as starting from a clean save.
Don’t pre-suggest the next change in the same reply as shipping the current one — the temptation is to “save a round trip” by listing the obvious next step in the closing paragraph. Skip it. OG explicitly tells you what to do next, in their own words, and not from your menu.
- Push a change → ship the URL → STOP. Don’t preemptively
queue the next change. OG’s verbatim signal phrases that mean
“I tested, this is good, what’s next?”:
-
OG fences scope at the user level: don’t make changes they didn’t ask for. Verbatim (Spire-Defense 2026-07-26): “The enemies are tracking towards the center of the lanterns, currently. That is fine for now. Don’t make the changes for that until I ask for it explicitly. We may get around to that in the future when we do graphics work.” Three scope disciplines:
- Generic ask = scope-free — a generic request is scope-free until the user names a project.
- Active project context is a trapdoor, not a default — don’t auto-import active project context as the default frame.
- Explicitly deferred work is also off-limits — if OG says “don’t make X until I ask”, X is on hold until they ask, even if it looks obviously broken or trivial. Don’t bundle a “while I’m at it” fix for X into a different change. Don’t surface X in your “what next?” suggestions. Wait.
-
The visual tuning ladder — bump up first, dial down. OG tells you when a visual change is too subtle by saying “I didn’t notice it” (Spire-Defense 2026-07-26, after 20px/tier spire growth was too small to read). The fix is NOT to pick a slightly bigger number on the first attempt — pick one that’s clearly too big, ship it, let OG confirm the wiring works, then tune down to their taste.
Pattern for visual numeric tuning:
- First commit: 3-5× the obvious target. Spire px/tier target was ~30, ship 60. Halo radius target was ~40, ship 100. The point is the player can unmistakably see the change.
- OG responds with “too dramatic” or “a bit much”.
- Second commit: dial down to OG’s taste. Common OG tuning results: 60→20 for subtle growth, 100→40 for subtle halo expansion. The exact final number is always OG-tuned; never assume.
Going big-to-subtle separates “is the effect firing?” from “is the magnitude right?”.
-
Per OG (Spire-Defense 2026-07-26): one height growth event per tier. When a milestone-driven system visually grows a structure (spire, tower, base), OG wants exactly ONE growth event per tier — the moment that defines the tier, not a ladder of micro-growths. Concretely: spire grows at wave 5 (the “First Growth” milestone, label matches the effect) and nowhere else in Tier 1. Wave 25/50/100/250/etc. carry effects like
milestoneToastorshopUnlockbut never another spire growth. Each tier’s defining growth moment is one event, not a curve. -
Direct
state.state.<field>mutation needs explicit event emission, or the HUD shows stale data for up to one tick. The GameState setters emit the canonical event on each mutation so listeners refresh immediately. Code paths that bypass the setter and write tostate.state.<field>directly skip the event, and the HUD reads the old value until the next TICK fires (50ms cadence). For batched wipes where multiple fields update before the UI refreshes once, emit the event manually at the end of the batch. Audit rule: every place in the codebase that writes tostate.state.<field>directly (not via a setter) needs an accompanyingbus.emit(EV.<X>, payload). -
Defer state mutations past a UX barrier so the player gets a chance to act. When a state-changing event fires (beacon death, prestige reset, run end), DON’T immediately wipe in-memory resources the player might want to spend in the shop before confirming the transition. The trigger event sets up the UX barrier (prompt, modal, button), and the confirmation click handler does the actual mutation. Players have one last interaction window between the trigger and the confirmation; don’t pre-empt it.
-
recomputeDerivedState(state)— pattern for transitioning a persisted field to derived. When a field that was once persisted becomes derived (e.g.spireHeightwas saved as a counter but is now computed fromunlockedMilestones), add a staticrecomputeDerivedState(state)method that the bootstrap calls afterSaveManager.load(). This is also the recovery path for the “stale corrupt save with the wrong derived value” bug — the recompute rebuilds it from the inputs, so the next load self-heals. -
_applyEffects(milestone)keeps data + handlers co-located. When a milestone schema supports multiple effect types, the data lives inTIER_1_MILESTONES(effect entries inline) and the handler lives in the same module as the data (MilestoneSystem). Adding a new effect type means adding a case to one switch statement — no engine-wide wiring change.Verified on Spire-Defense 2026-07-26 (commits
c16d430for spireHeight,697f77bfor milestoneToast). -
When milestones unlock content, enumerate the persistence-vs-reset semantics up front. A milestone that unlocks a shop item, weapon, or autobuy slot cannot reset between runs — locking content the player paid to earn is the canonical frustration pattern of poorly-designed idle games. Split the data layer (persistent + ephemeral) and route through two different sets:
state.unlockedMilestones(persisted) vs in-memoryannouncedThisRunSet (cleared onnewRunStarted).Verified miss 2026-07-25: a first implementation cleared an
firedMilestonesSet onnewRunStartedto “let the player re-encounter each milestone” — but that Set drove both the announcement AND the unlock. Clear the wrong half and you silently re-lock every shop item and weapon upgrade the player bought. The fix was renamingfiredMilestonestoannouncedThisRun(ephemeral, cleared) and adding a separatestate.unlockedMilestonesarray (persistent). Name the set after what it does. -
Decimal type coercion is silent through the EventBus. Patashu’s
break_infinity.jsthrowsTypeError: t2.indexOf is not a functioninsidefromString()when you constructnew Decimal(true),new Decimal(false), or passundefined. If that bad call happens inside a bus handler, the EventBus’s try/catch swallows it AND the loop keeps running — gameplay silently halts (no enemies spawn, no oil accumulates, zero errors logged). Always add atoDecimalish(v)helper at the boundary where data tables cross into the Decimal layer. -
Browser caches
.jsfiles acrossbrowser_navigatecalls. Python’shttp.serverreturns304 Not Modifiedand the browser keeps running the old code, so your “fix” appears to do nothing. Two ways out: (a) restart the server on a NEW PORT each iteration (kill old, start fresh), or (b) append a cache-buster (?v=N) and bump per change. Port-restart is more reliable because the cache-buster only defeats the file cache, not the JSON-serialised state in localStorage which can carry stale-shape data forward into a new-shape loader and look exactly like a “fix that didn’t apply”. -
Clearing localStorage isn’t enough after a schema change. If you change
_freshState()and there’s a saved blob with the old shape, the next page load restores the old shape — the new code reads stale fields and your “fix” looks broken. You need ALL of: (1)localStorage.clear()in the dev console, (2) a fresh port or cache-busted URL, (3) verify viaJSON.stringifythat the running state matches the fresh default. -
browser_consolereturns empty for cells the agent doesn’t read. -
Modal-triggering async handler called from a sync rAF tick path can silently swallow the show-overlay call. This is the Dark-Mine-Runner 2026-08-16 silent-overlay bug. The rAF
onTickcallback firesthis.onStratumComplete()(or equivalent modal-trigger callback) as a fire-and-forget call. That callback isasyncand starts withawait showOfferOverlay(...). The synchronous part runs fine (game.completeRun(),addCrystals, etc.), state visibly mutates, so the player sees a “completed” run. But if the overlay’sPromiseconstructor throws beforedocument.body.appendChild(e.g. a bad selector, a closure-capturednull, or a typo ininnerHTML), the overlay element never enters the DOM, AND the function suspends at theawait. The cart freezes correctly (_completing = truesentinel in the tick path bails early) so nothing visibly crashes — the game just sits there with the cart pinned at depth. Diagnostic signature: state is post-completion (currentStratum: null, crystals awarded, traces unchanged) butdocument.getElementById('offer-overlay')isnull.Fix rule: when firing an async handler from a sync rAF callback, wrap the
awaitin a try/catch that resolves to a safe default AND log every step withconsole.log('[DMR] <step>'). The catch default unblocks the rest of the handler so the cart’sendRun()still runs. The console.log sequence tells you whether the overlay function entered the DOM (about to show offer overlay), threw (overlay error: <msg>), or resolved (overlay resolved {…}). Without the catch + logs you see no error and no overlay — a true silent failure.Verified on Dark-Mine-Runner 2026-08-16: the overlay’s Promise constructor’s
addEventListener('keydown', onKey)setup ran inside the Promise executor, and a code path threw (Cannot read properties of undefined) that prevented the appendChild from completing. The sync parts of the handler ran, the async parts silently suspended at theawait, and the bug presented as “the offer overlay doesn’t appear” with zero console output. -
Run-side construct without state-side init causes the same silent-overlay symptom from a different root cause. This is the second occurrence on Dark-Mine-Runner 2026-08-16 — same day, same project, same observable symptom (overlay missing, no console errors, cart frozen at depth) but a different bug.
main.js startRun()constructed the run-side objects (Stratum,Cart,RunLoop) and setinRun = truebut never calledgame.startRun(seed, tier)to populategame.state.currentStratum. The cart still descended (Stratum owns its own depth/seed), the depth check firedonStratumCompletecorrectly, buthandleStratumComplete’sconst summary = game.completeRun(); if (!summary) return;early-returned becausecurrentStratum === null. The sync prelude (addGems,addCrystals) never ran; theawaitwas never reached; no error, no warning, no overlay. The “construct-OK-but-state-empty” gap is silent because nothing reads the persistent state during the run — the bug only surfaces on the first completion event when something tries to read what was supposed to be initialized at entry.Discriminator from Bug #1: same overlay-missing symptom, but in Bug #2
crystalsandgemsare UNCHANGED after the supposed completion (handler bailed before mutations). In Bug #1 they INCREASED (sync prelude ran before the Promise threw). Reading those two fields after a no-overlay bug is the discriminator.Fix rule: every run/loop/dungeon/wave entry point must pair the run-side construct with the persistent-state-side init. If you write
new Stratum(...)andnew RunLoop({...}), the matchinggame.startRun(seed, tier)(or equivalent) belongs between them. Make the read site defensive but loud: replaceif (!summary) return;withif (!summary) { console.warn( '[handleXxx] missing summary — game.startRun() never called?'); return; }. The warning surfaces the gap instead of silently swallowing it. Without the warning, this exact bug recurs every time someone refactors the entry point.Generalizes to any game with parallel state containers (run-side session object + persistent aggregate). Audit rule for any new run lifecycle: grep the run-side constructor for what persistent fields it reads, then verify those fields are set in the matching state-init call. If the run-side constructs fine without the persistent side being initialized, you’ve created the gap. See
references/async-overlay-from-raf-silent-failure.md(updated 2026-08-16) for the full diagnostic checklist with both bug variants and the discriminator table. -
The browser tool session can wedge on a native dialog that the agent cannot dismiss. When
page.confirm()/window.confirm()fires in the browser, thebrowser_navigate/browser_console/browser_clicktools all block with the same error:"A JavaScript confirm dialog is blocking the page: <text>. Resolve it with dialog accept or dialog dismiss, then retry …". The agent’sbrowser_console action='dismiss'/action='accept'calls return{"console_messages": []}and do NOT clear the dialog — they target page-level JavaScript dialogs (the new CDP protocol), not the native browser dialog queue. Thecomputer_usetool’scua_browser_dialogaction refuses withbrowser_mutation_unprovenunless the typed-browser binding is set up (status=ok, binding_quality=exact, mutation_allowed=true), which it is not on a plainlocalhostdev server. Killing the underlying firefox process does NOT clear the agent’s browser session — the tool’s state is separate from the browser process.Fix rules:
- Never use
window.confirm()/page.confirm()in canvas game code. They’re a bad UX pattern anyway (modal that can’t be styled, blocks on iOS Safari) and they wedge thebrowser_*tools. Replace with an in-page modal panel that uses your existing.panelstyling. - If a session is already wedged, the only recovery is to
fall back to JSDOM smoke tests + source-string
assertions via
fetch()(seereferences/source-string-assertion-pattern.md) until the user restarts the browser context manually. The wedged session does NOT auto-recover. - Always have at least one non-browser verification path
wired before deploying a canvas game. JSDOM + the source-
string assertion pattern is the cheapest: both run in node,
both validate 80%+ of the game logic without a browser, and
neither depends on the
browser_*tool state.
Verified on Dark-Mine-Runner 2026-08-16: a single
confirm('Abort the run? …')call in$dropCart’s click handler wedged the browser tool for the rest of the session; even after killing firefox, everybrowser_navigatereturned the same dialog block error. The session recovered only when JSDOM + source- string assertions were used for the remainder of the build. - Never use
-
Bright bloom on canvas can hide the things it shouldn’t. When a game draws radial-gradient halos, lantern glow, particle bloom, or any additive warm-colored fill on the canvas, those fills cover the render area but the player needs to see the silhouettes behind them. Draw order rule: bg → ambient overlay → beacon halo → spire/tower on top of bloom with a dark outline → bloom (lanterns, particles) → enemies drawn AFTER bloom with dark-outlined bodies → HUD text. Add a 1px
rgba(0,0,0,0.6)stroke around both the spire and the enemy bodies so silhouettes read against the glow. -
Don’t let contact-only enemies block your spawn cap. In TD / bullet-heaven games with
maxConcurrentenemy limit, enemies that deal one contact hit then linger forever will pile up at the spire and fill the spawn cap, blocking new spawns and stalling the wave. One-shot enemies should die on first contact. Verified on Spire-Defense 2026-07-24. -
Spawn quota is per-wave, not per-run — and bosses are ADDITIONAL to it. OG’s “100 enemies per wave” meant exactly 100; a boss at wave 200 is on top of 100 (so wave 200 spawns 101 total).
-
Reward milestones need difficulty partners, or players over-scale. OG (Spire-Defense 2026-07-26): “Some milestones should increase difficulty as well. Too many rewards and upgrades too quickly and players get overpowered and survive easily and get bored.” When a milestone grants a
shopUnlock,spireHeight, or new weapon, the next milestone in the same band should often tighten pressure (enemySpeedMult,enemyDamageMult, spawn cadence). Reward-only milestones stacked → player outscales the difficulty curve and disengages. Pattern: reward-difficulty alternation in early tiers. -
Don’t ship milestone effects for waves the player hasn’t reached. OG (Spire-Defense 2026-07-26): “We will design later milestones when we get there.” Ship empty
effects: []on milestones whose behavior OG hasn’t named. The temptation is to pre-fill future milestones with educated guesses — but each pre-filled milestone locks in a design decision OG hasn’t approved. When OG names wave 100’s effect, only fill in that one. -
Wave-gated shop unlocks are catalog entries, not code changes. Spire-Defense’s
Shop.jsalready filters by!item.hidden && (!item.requiresWave || wave >= item.requiresWave). Adding a wave-gated shop item is a one-catalog-entry change tosrc/shop/catalog.js. No Shop.js wiring needed. -
OG’s tier categorization (Spire-Defense 2026-07-26): Tiers 1–3 = early game, 4–6 = mid game, 7–10 = late game, 11+ = end game. When designing milestone cadence or pacing density per tier, match the band: early tiers get short cadence (waves 1, 5, 10, 25, 50, 100) so the player sees progression every session; late tiers get longer gaps. Tier-band names also affect copy: “early game” = “player is learning the verbs”, “end game” = “player has mastered them and is chasing final unlocks”.
-
Diagnostic signature: “X doesn’t visibly change on dev speed” where wiring IS correct → baseline magnitude bug, not wiring bug. When OG reports “no visible difference” with a dev-tool multiplier where the wiring looks correct, check the baseline magnitude before debugging the listener. The “visual tuning ladder” pattern (bump 3-5× the obvious target, let OG confirm wiring works, dial down to taste) catches this earlier.
-
Don’t add a
beforeunloadpersist listener alongside a Reset Save button. The reliable fix is to only persist on explicit in-game actions; the 30s autosave handles steady-state persistence. Drop the unload hook entirely. If you must keep it, gate the handler behind a “resetting = true” flag. -
Don’t
const(noexport) a constant you intend to import. Same bug as the import-resolves-to-undefined issue, worth its own item because the symptom (silentundefinedpropagating through a comparison) doesn’t show up in any tool’s output. Treatexport constas the default for any constant that crosses a module boundary. -
When fixing a bug in a conditional, enumerate every branch. Diagnostic signature: a fix lands, OG reports “the bug is still there but only sometimes / only at certain waves”. When that happens, the bug has more than one trigger path. Find them all by grep’ing for every code path that ends in the bad mutation and verify each one is updated.
-
Orbit-angle / per-instance state drift across save rounds.
-
Two input sources both writing the same state: the silent-clobber bug. When two code paths (e.g. pointer-drag steering and keyboard steering) both target the same cart/game state field every frame, the path that runs LAST wins every frame. The losing path silently loses — no error, no warning, just a feature that doesn’t work. Dark Mine Runner v2 (2026-08-16) hit this:
applyKeyboardSteering()ran every frame inside the master loop and calledrunLoop.setSteer(0)whenever no keyboard key was currently held. Pointer-drag steering ALSO wrote tocart._steerInput, but the keyboard path’ssetSteer(0)ran AFTER and clobbered it. The cart looked immovable. Diagnostic signature: any “input that uses setState every frame” feature that stops working the moment another input source exists. Fix rule: input sources that write the same state must be mutually exclusive in time. If pointer is active, pointer wins. If a key is held, keyboard wins. If neither, leave the state alone — don’t reset to 0. The “reset to 0 on no-input” is the trap: it makes the input look dead when in fact the OTHER source is being overwritten. Pattern: each per-frame input handler first checks whether its source is active, and only writes to state when it is. Implementation:if (keysDown.size === 0) return;at the top of the keyboard handler. Apply same gate to drag:if (!pointerDown) return;. Both writers then return; the other never overwrites. Generalizes to any “two paths writing same field every frame” pattern: pinch-zoom + scroll-wheel both touching scale, mouse + keyboard both touching selection, two polling loops both touching connection-state. -
Constructor/init code that says
this.x = 0and is never overwritten by the player is a hint that the player was never supposed to control it. Dark Mine Runner v1 (2026-08-16) shipped withCart.startRun()settingthis.x = 0and nothing else ever writingcart.x— so the cart was pinned to the left edge of the canvas for the entire run. The user’s feedback was direct: “The cart only descends along the left side of the screen, so the gameplay is never center focus for the player.” When auditing a class, grep for every write to each numeric field — ifcart.xis only ever written instartRun()and never by input or by physics, the player can’t move it. The bug is invisible until you look at the canvas itself with a pixel probe or until you deliberately test steering. Cross-reference: the canvas pixel probe pitfall above catches this before shipping, in seconds. Dark Mine Runner shipped this bug despite 58 passing unit tests because every test checked numbers in isolation, not the rendered output. The fix: always do at least one visual end-to-end check in a real browser before declaring “done.” -
Shipping without a visual check is shipping untested. The single highest-value pre-ship check for a canvas game is: open the page in a real browser, capture a screenshot or pixel-probe the canvas, and confirm the rendered output matches what the design described. Dark Mine Runner v1 passed 58 unit tests, committed clean, had a green CI-style assertion in the README, and shipped with the cart literally pinned to the left edge of the canvas. Tests cover logic; tests do NOT cover rendering. A 30-second pixel probe of
getImageData(cartX, cartY)would have caught it. Make the visual check load-bearing: add a step to the build protocol like “before commit, browser_navigate + canvas pixel-probe + confirm cart is at viewport center.” If you’re in an autonomous build with no human in the loop, the pixel probe IS the human’s eyes — don’t skip it because “tests pass.” Pattern: after every scaffold-complete commit, the next action is visual verification. If you can’t visually verify (browser tool wedged, no time, no screenshot tool), say so in the commit message and ship at a lower confidence mark.
References
references/canvas-game-architecture.md— full architecture with the bootstrap-order contract, fixed-timestep accumulator pattern, DPR details, save/cold-restart reasoning, per-archetype data design, design-elicitation workflow, and Spire-Defense’s verified skeleton.references/orbiting-weapon-patterns.md— Fibonacci spiral lantern layout (4-per-layer with golden-angle rotation), 6-arm Fibonacci distribution with halo cap (Pattern 1b: linear radial step + sharedgetMaxHaloRadius()source for weapons), kill-on-touch rule for one-shot contact enemies, rAF + setInterval side-by-side, z-order rule for bright bloom on canvas games (draw foreground AFTER background bloom with dark outlines for contrast).references/spire-defense-session-learnings-2026-07-24.md— in-session notes covering the soft-reset vs hard-reset distinction for run-end, the Save-with-Continue-Run gate pattern, the cache-buster-required-by-default rule, and the workflow rules OG stated explicitly in-thread.references/spire-defense-session-learnings-2026-07-26.md— later-session notes (the bisect-after-revert cadence, OG’s user-level scope fencing, visual tuning ladder, derived-state recomputation, the milestone data-layer + handler co-location pattern, and the per-tier one-growth-event design rule).references/spire-defense-session-learnings-2026-07-26-continued.md— same-day continuation (halo tier-scaling tune-down, wave-gated shop catalog entries, the difficulty-vs-rewards pacing DNA, OG’s tier-band categorization, and the don’t-pre-design-future- milestones rule).references/spire-defense-session-learnings-2026-07-26-final.mdreferences/button-flash-confirmation-pattern.md— the canonical recipe for “press button → see/hear action confirmed” (class-tagged button + single-purpose CSS class + reflow-then-class-toggle + setTimeout cleanup + audio in same success branch, per-verb color differentiation). Four shipped DMR v3.2-v3.3 examples (deploy lantern, heirloom buy, cartwright equip, expedition launch) and the verify contract for any future verb-class confirmation. —references/threshold-celebration-modal-pattern.md— the recipe for irreversible threshold-crossing events (prestige reset, ascension, rebirth, NG+, faction betrayal, dimension hop) — gold-bordered modal that pulse-scales in, KEPT/RESET comparison panel, first-vs-Nth copy branching, one-time welcome toast gated on a save flag, dedicated gold-bell + ascending-shimmer audio cue. Distinct from the verb-flash recipe — threshold events warrant a different surface class because the action is irreversible and the player needs to see what survived vs. wiped, not just ‘did the click register.’ Captured from DMR58d2eb1prestige modal ship. Use when ALL of: action is irreversible, state shape changes meaningfully, first crossing is emotionally distinct from Nth, KEPT-vs-LOST comparison matters at the moment of crossing. Otherwise use the verb-flash recipe. same-day final session (the dev panel architecture:dev:events on the bus, per-subsystem flags, dtMs-level speed scaling, no-persistence rule, and the “use state.setWave not raw currentWave” wiring rule).references/spire-defense-session-learnings-2026-07-26-run-end-unification.md— same-day session (the duplicate End Run / beacon-death handlers and modals, thebus.emit(BEACON_DEAD)unification fix, and the user-verification-first rule when reports contradict your model).references/spire-defense-session-learnings-2026-07-26-dev-speed-setinterval.md— dev-panel follow-up (independent setInterval tickers bypass GameLoop dtMs scaling, so each visible time-driven subsystem needs its owndev:speedsubscription; the visual-tuning refinement that dev-tool time multipliers need a slow baseline so the multiplier is visibly readable).references/spire-defense-session-learnings-2026-07-26-dev-panel.md— dev panel implementation, defensive_wipeRunStatefor HUD staleness after beacon death, and the “mutate state + emit event” pattern when bypassing setter methods.references/spire-defense-session-learnings-2026-07-26-dev-system-and-weapon-registry.md— the Weapon base class + WeaponRegistry pattern for sharing dev hooks across N subsystems, the “look at old code” directive workflow (find historical anchor, view old code, diff against current, restore as close to old as possible), the revert-vs-keep-patching rule of thumb, and the “stable version” tag workflow.references/spire-defense-session-learnings-2026-07-26-weapon-self-description.md— weapons self-describe shop data (one source of truth per Weapon), Shop reads from weapons registry instead of a separate catalog, the active-gate pattern (isActive() = purchased AND unlocked) applied to bothupdate()/draw()AND Shop card rendering, OG’s “investigate first then patch” instruction with the diagnostic format that worked, and theshopId ↔ state.upgradeskey-match gotcha.references/spire-defense-session-learnings-2026-07-26-shop-cards-and-tier-unlocks.md— the DOM-element-persistence pitfall (TICK-drivenbody.innerHTML = ''rebuilds destroy cards mid-tap, fixing by tracking stable keys + in-place content updates), OG’s “stop blaming Safari” as a workflow signal, the one-time-vs-per-run milestone gate (usestate.unlockedMilestones.includes('${tier}:${wave}')notcurrentWave >= unlockAtWave), tier-specific milestone keys for cross-tier isolation, and the “force-reflect defensive fix that masks the real bug” trap.references/spire-defense-session-learnings-2026-07-26-per-player-saves.md— per-player save system (ported from idle-scaffold:sanitizePlayerName, per-player keys via${STORAGE_KEY}.${name}, player index, picker modal), theserver.pypattern for iOS Safari cache defeat (custom Python server withThreadingMixIn+no-storeheaders + 30s socket timeout — meta tags in HTML aren’t enough because Safari caches module imports separately), the shared “Begin New Run” helper helper pattern (when two UI actions converge on the same state transition, factor a single helper or event), and the storage-key-bump-as-migration tradeoff (cheaper than writing migration code for canvas-game pace, but loses in-progress saves on schema change).references/aura-shape-visual-iteration.md— multi-round visual iteration pattern for aura-shape enemies with reference images (diagnose-first pixel-sampling, geometry-count verification via pathForShape command-counting, minimum-viable-geometry-first rule, and the 3 framing questions to ask before modeling any aura-shape archetype). Captured from the 2026-08-10 Shade feature.references/tier-gated-prose-pools.md— implementation pattern for in-game prose flourishes (whispers, ambient lines) that grow with player progression. Wave-organized content pool, eligibility gated at the boundary (not on the entry), additive-not-replacement waves, distinct voice per wave, throttle at the call site. Verified on Dark-mine-runner 2026-08-16 (30 lines, 3 waves, 0/5/15 shaft unlock thresholds). Reusable for any atmospheric canvas/narrative- idle game.references/async-overlay-from-raf-silent-failure.md— the fire-and-forget async handler called from sync rAF silent- failure pattern. WhenhandleStratumComplete(or any modal- triggering callback) is fired asthis.onXxx()from inside the rAF onTick, and the awaited Promise constructor throws BEFOREdocument.body.appendChild, the overlay never enters the DOM and no error is logged. Captured 2026-08-16 on Dark Mine Runner (showOfferOverlay’s Promise executor threw on a closure null). Diagnostic signature + try/catch + step-logging fix pattern. Generalizes to any rAF-driven async UI flow.references/silent-bug-archive-2026-07-29.md(under spire-defense-build-protocol) — three silent-bug classes that bit Spire-Defense on 2026-07-29:newon a function that returns a primitive (bosses had zero DPS for the lifetime of a helper), canonical-key drift between autobuy and shop paths, and shield regen without an HP gate. Each entry includes the diagnostic pattern that surfaced the bug. Use when debugging any of these symptoms — applies to all canvas games, not just Spire-Defense.references/spire-defense-session-learnings-2026-07-26-shop-redesign-fibonacci-iteration.md—\n full-screen Shop + Beacon Out panels with shared navigation\n helpers, the DOM-element-persistence pitfall (TICK-driven\nbody.innerHTML = ''destroys click-bound cards mid-tap; the\n fix is stable-key tracking + in-place content updates), the\n halo-clamping-to-100%-HP semantics (weapon range bounded\n by the halo at full HP for the current spire tier, NOT the\n live HP-shrunk value — the light radius is a stable\n per-tier reference), and the Fibonacci 6-arm layout iteration\n across three rounds (3-arm sqrt → 6-arm exponential → 6-arm\n linear) where the algorithm class was kept stable and only\n the parameters were tuned.\n-references/spire-defense-session-learnings-2026-07-26-profile-switch-and-state-refs.md—\n the Profile switchthis.statevsthis.saves.gameState\n reference trap (two classes sharing the same GameState\n instance under different field names — the wipe path silently\n errored onthis.state.constructorbecause PrestigeManager\n doesn’t have that field name as a real reference), the\n reload-on-profile-switch workaround (usingsessionStorage\n to pass the chosen player name across a self-induced\nlocation.reload()so the picker doesn’t show again and any\n browser-side state-cache quirk is bypassed), and the second\n occurrence in one session of the "stop blaming Safari" reflex\n — pattern: when defensive fixes don’t work, the bug is\n upstream; read your own code, default-flip to "I wrote a bug"\n on the next failure regardless of platform.
The user has provided the following instruction alongside the skill invocation: [IMPORTANT: You are running as a scheduled cron job. DELIVERY: Your final response will be automatically delivered to the user — do NOT use send_message or try to deliver the output yourself. Just produce your report/output as your final response and the system handles the rest. SILENT: If there is genuinely nothing new to report, respond with exactly “[SILENT]” (nothing else) to suppress delivery. Never combine [SILENT] with content — either report your findings normally, or say [SILENT] and nothing more.]
You are the autonomous build loop for Dark Mine Runner (v3.2-v3.3 sprint). Each tick: (1) cd /home/og/Dark-mine-runner && git log –oneline -5 to see the last chunks shipped. (2) Read .claude/CLAUDE.md or any plan file if it exists; otherwise treat the vein-A2 polish lane as: ship-treats-as-finish, defender-verb visible feedback, per-creature sprite polish, autobuy visibility, and any low-hanging UX gap. (3) Pick ONE small chunk (~15-60 min worth: a polish item, a sprite upgrade, a missing affordance, a bug found while reading code). Do NOT do big multi-system features. (4) Ship it: edit code, add a source-assert test, run npm test, commit, bump BUILD_VERSION in src/build.js to the new short SHA, commit again. (5) Post ONE terse line to the build-log thread via: hermes send -t ‘discord:1502452111248719892:1539012815569031298’ -q ‘<short-sha> <one-line summary>’. (6) Exit. The cadence rule from memory: ship-treats-as-finish; one terse post per tick; do NOT post version-number announcements to #dmr-status; do NOT spawn subagents; do NOT work on the spire-defense repo. If npm test fails, fix the regression before committing. If the chunk is already done in a prior commit, pick the next lane item.
assistant (2026-08-19T00:53:28)
<details><summary>reasoning</summary>The user is invoking the cron job build protocol for Dark Mine Runner. Let me follow the steps:
- cd to DMR and check git log
- Read plan file if exists
- Pick ONE small chunk
- Ship it
- Post to build-log
- Exit
Let me start by checking the current state.
</details>assistant (2026-08-19T00:53:33)
assistant (2026-08-19T00:53:35)
assistant (2026-08-19T00:53:37)
assistant (2026-08-19T00:53:47)
assistant (2026-08-19T00:53:49)
assistant (2026-08-19T00:53:50)
assistant (2026-08-19T00:53:52)
assistant (2026-08-19T00:53:56)
assistant (2026-08-19T00:53:57)
assistant (2026-08-19T00:53:59)
Let me look at the panels and their available actions to find a polish lane item. Let me check what verb-flash colors exist and what surfaces haven’t been flashed yet.
assistant (2026-08-19T00:54:03)
assistant (2026-08-19T00:54:05)
assistant (2026-08-19T00:54:07)
assistant (2026-08-19T00:54:09)
assistant (2026-08-19T00:54:27)
Now let me look at what surfaces still lack verb flash coverage. Let me check CartwrightPanel, HeirloomPanel, autopilot config, and the shop, prestige_modal, save-load flows:
assistant (2026-08-19T00:54:29)
assistant (2026-08-19T00:54:31)
assistant (2026-08-19T00:54:33)
Now let me look at the CartwrightPanel and the CityPanel:
assistant (2026-08-19T00:54:35)
Let me check the CityPanel and find a missing verb flash. Let me look at gameSpeed.js and confirm which surfaces have their own feedback:
assistant (2026-08-19T00:54:37)
Let me look at what’s at line 227 in CityPanel — that looks like a possible un-flashed verb:
assistant (2026-08-19T00:54:41)
Now let me check autopilot.js and the actual verbs. Let me look at the main run-loop for input feedback surfaces:
assistant (2026-08-19T00:54:44)
assistant (2026-08-19T00:54:45)
assistant (2026-08-19T00:54:48)
Let me check the _showResult rendering more carefully — there’s a clear polish opportunity. The result block just renders silently. Let me also look at expedition result:
assistant (2026-08-19T00:54:49)
assistant (2026-08-19T00:54:52)
Let me check what the SAVE button does — that’s a key persistent surface that probably lacks a flash:
assistant (2026-08-19T00:54:53)
assistant (2026-08-19T00:54:56)
assistant (2026-08-19T00:54:59)
OK save has a pulse. Let me look at what other persistent HUD surfaces still need attention. Let me look at the speed indicator:
assistant (2026-08-19T00:55:02)
Now let me look at the expeditions results rendering — the result text in CityPanel after the expedition. The flash fires on the LAUNCH button (initiation), but the actual RESULT after 60 seconds doesn’t have its own flash on the result block:
assistant (2026-08-19T00:55:04)
Let me look at the _log and the reset modal area — looking for what reset looks like visually:
assistant (2026-08-19T00:55:07)
Let me look at the catalog for the build verb (line 170) — does it have a flash? Let me also check the campaign-level surfaces:
assistant (2026-08-19T00:55:09)
Let me look at the main run-loop surface — what happens when a run ends, what’s the surfaces that exist? Let me look at the Defender flow:
assistant (2026-08-19T00:55:12)
Let me look at defender deploy button — does it have flash:
assistant (2026-08-19T00:55:20)
Now let me look at the campaign tier display. Let me see if the TIER advance celebration has a flash on a persistent HUD element:
assistant (2026-08-19T00:55:22)
assistant (2026-08-19T00:55:24)
Let me look at the HUD’s tier display and how the bottom-right tier indicator works. There may be no flash on tier change in the persistent HUD:
assistant (2026-08-19T00:55:26)
assistant (2026-08-19T00:55:29)
Now I see something important. The $tier row updates textContent but the minerTier increment is silent. Let me check what tier-up actually looks like:
assistant (2026-08-19T00:55:32)
Now let me see what evaluateTierUnlocks does — there’s both a minerTier and the meta-tier evaluation:
assistant (2026-08-19T00:55:34)
Now let me check the bottom-row tier indicators (the small “Stratum N” badge):
assistant (2026-08-19T00:55:36)
Excellent — the #stat-tier row updates textContent from Stratum 1 → Stratum 2 → … but the transition is silent! This is a clear polish item: the tier-row should flash when minerTier increments (just like stat-run got a state-transition flash in commit 12).
Let me check how minerTier is incremented and where:
assistant (2026-08-19T00:55:38)
The miner tier is mutated in two places: main.js:797 (autoUnlockMinerTier) and presumably anywhere the player manually buys their way to the next tier. Let me check the showHint flow:
assistant (2026-08-19T00:55:42)
I see — showHint already fires New miner unlocked: ... at line 798. So the player gets a toast but the persistent #stat-tier row just silently mutates. Now this is a perfect polish chunk — give the persistent tier row a flash on transition (parallel to the stat-run state-transition flash from commit 12).
Let me check what color the tier-up modal uses to match it:
assistant (2026-08-19T00:55:44)
assistant (2026-08-19T00:55:46)
Now I have a clear lane: stat-tier row state-transition flash when minerTier increments. This is parallel to commit 12’s stat-run state-transition flash, but for the tier progression. The persistent #stat-tier row updates Stratum 1 → Stratum 2 silently — same shape of silent-mutation bug.
Let me check the existing dmrTierFlash keyframe to make sure I use a distinct one:
assistant (2026-08-19T00:55:49)
Good — dmrTierFlash is the dismiss button flash (deep amber 217,119,6). I need a separate keyframe for the persistent tier row. Let me pick a distinct gold-starlight flash to match the tier-up modal’s color (#f0b429) — but in a subtler form since it’s a persistent HUD row, not a button.
Now let me also check what shows for stat-tier visually:
assistant (2026-08-19T00:55:51)
Now let me check tests dir and the pattern for test additions:
assistant (2026-08-19T00:55:53)
assistant (2026-08-19T00:56:00)
assistant (2026-08-19T00:56:01)
assistant (2026-08-19T00:56:03)
assistant (2026-08-19T00:56:06)
Now let me look at exactly how the run-state flash is structured to mirror it:
assistant (2026-08-19T00:56:09)
assistant (2026-08-19T00:56:12)
Now I have a clear plan. Let me look at the existing tests for the run-state flash to mirror them and then write the tier-row variant:
assistant (2026-08-19T00:56:14)
Good — I have the template. Now let me look at the run-state test pattern more carefully, find the readSrc definition and test setup:
assistant (2026-08-19T00:56:16)
Good. Now let me find a good insertion point. The run-state tests are at the end. Let me append the new tier tests right after them. Let me first write the actual changes:
1. Add CSS keyframes + bindings to index.html:
assistant (2026-08-19T00:56:24)
Now I’ll add the new keyframes + binding after the run-state return flash:
assistant (2026-08-19T00:56:30)
Now wire the flash in main.js. I need to add a transition detector for minerTier right after the existing stat-run transition block:
assistant (2026-08-19T00:56:32)
Now add the _tierPrev module-scope tracker near _runStatePrev:
assistant (2026-08-19T00:56:35)
assistant (2026-08-19T00:56:37)
assistant (2026-08-19T00:56:46)
Now add the source-assert tests:
assistant (2026-08-19T00:57:01)
Now let me also create the JSDOM eye-check script:
assistant (2026-08-19T00:57:11)
Wait — I need to fix a syntax issue in my JSDOM script. The template string with escaped quotes is broken. Let me rewrite it:
assistant (2026-08-19T00:57:13)
Now let me run the test suite and the eye-check:
assistant (2026-08-19T00:57:16)
194 passing, 0 failed (was 192 before, +2 from commit 13). Now let me run the full test suite + eye-check:
assistant (2026-08-19T00:57:19)
Eye-check passes. Now let me run the full test suite:
assistant (2026-08-19T00:57:22)
assistant (2026-08-19T00:57:28)
All test suites pass: 18+22+194+45+9+11+11+14+18+11 = 353 passing total (up from 349 before + 2 new = 351, plus 2 from commit 13 = 353). Let me confirm the count:
assistant (2026-08-19T00:57:31)
353 tests pass (was 351 before — +2 new from commit 13). Now let me commit, bump BUILD_VERSION, and post to the build-log channel:
assistant (2026-08-19T00:57:43)
assistant (2026-08-19T00:57:45)
Now bump BUILD_VERSION:
assistant (2026-08-19T00:57:47)
assistant (2026-08-19T00:57:50)
assistant (2026-08-19T00:57:51)
Good. Now commit the build bump:
assistant (2026-08-19T00:57:54)
Now push to origin:
assistant (2026-08-19T00:57:57)
Push succeeded. Now post to the build-log channel:
assistant (2026-08-19T00:58:02)
Posted. Let me verify everything is good:
assistant (2026-08-19T00:58:06)
Chunk shipped. Phase 12 commit 13: stat-tier row stratum-up flash.
Summary of what shipped this tick:
- CSS (
index.html): new@keyframes dmrTierUpFlash(warm-gold rgba(240,180,41), 700ms, scale 1.00→1.18→1.00) +#stat-tier.tier-up-flashbinding - JS (
src/main.js): transition detector comparingminerTier !== _tierPrevin refreshHud + module-scope_tierPrev = 0tracker - Tests (
test/source-assert.mjs): 2 new source-assert tests covering the JS transition + CSS keyframe - Eye-check (
/tmp/phase12-commit13-eye-check.mjs): JSDOM walkthrough of stable→0→1→stable→1→2 cycle + 740ms setTimeout cleanup + 3 rapid transitions + CSS keyframe assertions (all pass) - Tests: 351 → 353 passing (+2)
- Build: BUILD_VERSION bumped
067ad3c→c41f511 - Channel: posted to
#dmr-build-log - Push:
c078aa5..0dbbcb2to origin/main (8 local commits replayed onto the prior remote tip)
Lane item: the persistent #stat-tier row updated Stratum 1 → Stratum 2 → ... silently even though the tier-up modal already celebrated the same moment. Same silent-mutation bug class that the stat-run row got fixed for in commit 12 — different row, same shape, distinct verb axis (one-way warm-gold vs two-way lime-go/gold-back).
Comments (0)
No comments yet.
Log in to post a comment.