The Dive — Implementation Plan (web prototype)¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make a session one continuous descent — descending banks nothing and continues the run, safe exits bank and advance the checkpoint, and the haul plus your wounds compound across floors — all behind a dive.enabled master flag.
Architecture: No new files. config.js gains a dive group (plus one rival knob); game.js gains a descend path that reuses newRun's map/enemy setup via an optional carry bag, a run-scoped card system, and an at-risk HUD readout. Every rule is gated on diveActive() so the pre-dive game remains playable by flipping one switch in the Tuning panel — the same A/B discipline every action-pivot rule shipped under.
Tech Stack: HTML5 Canvas + vanilla JS, no build step, no dependencies. Design source: docs/design/dive-structure/gdd.md.
Global Constraints¶
- No test suite or linter exists in this repo (
CLAUDE.md). Verification isnode --checkfor syntax plus scripted manual browser checks. Do not introduce a test framework — this is a design-validation vehicle, not a shipping codebase. - Never inline a magic number. Every tunable goes in
config.jsDEFAULTS, gets aCONFIG_METAblurb, and is read at the use-site asCFG.<group>.<key>. - Every dive rule is gated on
diveActive(). Withdive.enabled: falsethe game must behave exactly as it does today. - The tutorial and sandbox runs never dive.
diveActive()returns false for both. - Cards are run-scoped. They live on
G, never touchsave. - Code, comments and identifiers are English. Match the surrounding terse, data-driven style.
- Design values, verbatim from the GDD: depth bonus 0.25/descended floor, forced-heal threshold 0.40 HP, cards offered 3,
map.roundTime180.
Task 1: Config — the dive group¶
Files:
- Modify: config.js (DEFAULTS, CONFIG_META)
Interfaces:
- Produces: CFG.dive.{enabled, depthBonusPerFloor, healCardHpFrac, cardCount}, CFG.rival.oppHaulPerDescent, CFG.map.roundTime = 180
- [ ] Step 1: Add the
divegroup toDEFAULTS, placed directly after theproggroup so the structural knobs sit together in the Tuning panel.
dive: {
enabled: true, // reload — MASTER A/B: the run-as-session structure. Off = the pre-dive game (descend banks + ends the run)
depthBonusPerFloor: 0.25, // live — bank-time multiplier on CURRENCY per floor DESCENDED this run (not floor number). Deliberately conservative: it compounds on top of the rarity ramp, which already scales with depth
healCardHpFrac: 0.40, // live — below this HP fraction the Body card is FORCED to a heal (anti death-spiral rail)
cardCount: 3, // live — cards offered at each descent (one per category: body/edge/greed)
cardHealFrac: 0.45, // live — Patch Up restores this fraction of max HP
},
- [ ] Step 2: Add
oppHaulPerDescentto therivalgroup, immediately afteroppHaul.
oppHaulPerDescent: 3, // live — the fat-haul threshold grows by this many items per floor descended. Without it the bag never resets on a dive, so every Opportunist would be permanently triggered from ~floor 2
- [ ] Step 3: Set
map.roundTimeto 180. Find theroundTime: 330line and replace it, keeping the comment style:
roundTime: 180, // re-roll — PER-FLOOR closure schedule (s). Under the dive this no longer ends the raid (no timer death) — it only drives which safe exits shut. The closing schedule scales off it automatically (fractions)
- [ ] Step 4: Add the
CONFIG_METAblurbs, next to the other group entries:
"dive.enabled": "the run-as-session structure: descending banks nothing and continues the run; only carried-out loot is kept",
"dive.depthBonusPerFloor": "bank-time currency multiplier per floor descended this run (counts descents, not floor number)",
"dive.healCardHpFrac": "below this HP fraction the descent's Body card is forced to be a heal",
"dive.cardCount": "how many upgrade cards a descent offers",
"dive.cardHealFrac": "fraction of max HP the Patch Up card restores",
"rival.oppHaulPerDescent": "how much the Opportunist fat-haul threshold grows per floor descended",
- [ ] Step 5: Verify syntax
Run: node --check config.js
Expected: no output (success)
- [ ] Step 6: Verify the values load
Run: node -e "global.window={};require('./config.js');const c=window.CONFIG;console.log(c.dive, c.rival.oppHaulPerDescent, c.map.roundTime)"
Expected: the dive object printed, 3, 180
- [ ] Step 7: Commit
git add config.js
git commit -m "Dive: config group (master flag, depth bonus, card rails) + roundTime 180"
Task 2: diveActive() and run-scoped dive state¶
Files:
- Modify: game.js — near raidStep()/pivotTutorial() (~line 406), and newRun's G = {...} literal (~line 368)
Interfaces:
- Produces: diveActive(), G.descents (number), G.cards (array of card ids), depthMult()
- [ ] Step 1: Add the helpers immediately after
pivotTutorial()(~line 409):
// THE DIVE: is the run-as-session structure live right now? Real raids only — a scripted FTUE stage and
// Sandbox both stay single-floor (the tutorial teaches verbs; sandbox is a dev free-play).
function diveActive() { return !!CFG.dive.enabled && !!G && G.isRaid && !G.sandbox && !G.tutorial; }
// bank-time currency multiplier. Counts floors DESCENDED this run, not the floor number reached — so a
// deep START buys better tables while a long DIVE buys value, and "start deep, bank instantly" earns nothing.
function depthMult() { return 1 + (G.descents || 0) * CFG.dive.depthBonusPerFloor; }
- [ ] Step 2: Seed the state in
newRun'sG = {...}literal. Find the linekills: 0, chestsOpened: 0, extracted: false, shake: 0,and add after it:
descents: 0, cards: [], // THE DIVE: floors descended this run + the run-scoped upgrade picks
- [ ] Step 3: Verify syntax
Run: node --check game.js
Expected: no output
- [ ] Step 4: Commit
git add game.js
git commit -m "Dive: diveActive/depthMult helpers + run-scoped descent state"
Task 3: Retire the timer death; keep the reserve pad open¶
Files:
- Modify: game.js:1898-1907 (timer expiry), game.js:4013-4017 (reserve-pad arming)
Interfaces:
- Consumes: diveActive() from Task 2
- [ ] Step 1: Gate the timer death. Replace the
if (G.time <= 0 && !G.extracted) {...}block body so the dive clamps instead of ending:
if (G.time <= 0 && !G.extracted) {
// THE DIVE: no timer death. A hard expiry can't coexist with an accumulating haul — it would erase a
// multi-floor fortune by clock rather than by decision, several times a session. Time now only drives
// which safe exits have shut; the reserve pad and the descend portal both stay open.
if (diveActive()) {
G.time = 0;
if (!G._sweepAnnounced) {
G._sweepAnnounced = true;
worldEvent("sweep", "⌛ The easy ways out are sealed — the reserve pad is a long walk, or go DOWN.", "warn");
}
}
else if (CFG.extraction.stormFinale) { triggerCataclysm(); endRun(false, "The Wildmagic swallowed Thornwood — you vanished with it."); return; }
else { endRun(false, "The raid window closed — your unbanked haul is lost."); return; }
}
- [ ] Step 2: Exempt the reserve pad. At
game.js:4015, add the dive guard to the_finalSafearming condition:
// The reserved final safe pad normally arms its countdown once the descend opens (funnelling the endgame
// into the descend portal). THE DIVE exempts it: removing the last way to bank turns the design's central
// choice into coercion, and a player denied a cash-out on a big haul reads it as the game cheating. The
// funnel intent is carried by the depth bonus instead — reward pulling down, not denial pushing.
if (ex._finalSafe && !(ex.closesAt > 0) && !diveActive()
&& (G.bossDown || G.extracts.some(e => e.kind === "progress" && e.unguarded)))
ex.closesAt = Math.max(1, G.time - CFG.extraction.closingInterval);
- [ ] Step 3: Verify syntax
Run: node --check game.js
Expected: no output
-
[ ] Step 4: Browser check. Open
index.html, start a Biome 1 Floor 2 raid (floor 1 has no clock by design —closingFromStep). Let the clock run to 00:00 without extracting. Expected: the run does NOT end; the sweep banner fires once; at least one safe pad is still enterable. -
[ ] Step 5: Commit
git add game.js
git commit -m "Dive: timer stops killing the run; reserve safe pad never closes"
Task 4: The descend path — newRun(ctx, carry) and descendFloor()¶
Files:
- Modify: game.js:299-398 (newRun signature + player construction + G literal), and add descendFloor() after startRun (~line 5919)
Interfaces:
- Consumes: diveActive(), depthMult()
- Produces: newRun(ctx, carry) where carry is {bag, safe, gold, blueprints, hp, potions, cells, loadout, devKit, kills, chestsOpened, descents, cards} or null; descendFloor()
- [ ] Step 1: Accept the carry bag. Change the signature and the loadout source. Replace lines 299 and 309-312:
function newRun(ctx, carry) {
and
// the run carries its OWN loadout copy (origin:"stash") so a mid-run field-equip mutates the run,
// never the saved loadout, until it's banked at extraction/death. Sandbox/raid on a fresh, gearless
// save borrows the legacy starter kit (non-persistent) so dev free-play isn't powerless.
// THE DIVE: a descent CARRIES the run's live loadout (field-equipped finds included) rather than
// re-cloning from the save — the run keeps accumulating until it banks or dies.
let srcLoadout = save.loadout;
const devKit = carry ? carry.devKit : (ctx.kind === "raid" && loadoutEmpty(save.loadout)); // borrowed starter kit → never persisted
if (devKit && !carry) srcLoadout = starterLoadout();
const loadout = carry ? carry.loadout : cloneLoadout(srcLoadout, "stash");
- [ ] Step 2: Carry the player's condition. Immediately after
applyLoadout(player, loadout);andsyncWeapon(player, true);(~line 361), insert:
// THE DIVE: wounds come with you. Health, consumables and the run's card picks survive the descent —
// attrition is the second thing that compounds alongside the haul, and together they are what makes the
// question on each pad real. Clamp to the (possibly card-raised) max rather than trusting the old value.
if (carry) {
player.hp = Math.max(1, Math.min(carry.hp, player.maxhp));
player.potions = carry.potions; player.cells = carry.cells;
}
- [ ] Step 3: Carry the haul and run stats. In the
G = {...}literal, replace thebag/killslines:
bag: carry ? carry.bag : [], safe: carry ? carry.safe : null,
gold: carry ? carry.gold : 0, blueprints: carry ? carry.blueprints : 0,
and
kills: carry ? carry.kills : 0, chestsOpened: carry ? carry.chestsOpened : 0, extracted: false, shake: 0,
and the Task-2 line becomes:
descents: carry ? carry.descents : 0, cards: carry ? carry.cards : [], // THE DIVE: floors descended this run + the run-scoped upgrade picks
- [ ] Step 4: Add
descendFloor()afterstartRun(beforelet resultNext = null;):
// THE DIVE — take the descend portal WITHOUT banking or ending the run: the next floor generates and
// everything the player is carrying (haul, wounds, consumables, field-equipped gear, card picks) comes
// along. The map, its enemies, the vault + key, the floor guardian and the clock all reset — an unspent
// key is forfeit, which the descend copy states. Difficulty follows for free: raidStep() derives from
// G.stage, so mobIntro and the threat budget ramp on their own.
function descendFloor() {
const p = G.player;
const carry = {
bag: G.bag, safe: G.safe, gold: G.gold, blueprints: G.blueprints,
hp: p.hp, potions: p.potions, cells: p.cells, loadout: p.loadout, devKit: G.devKit,
kills: G.kills, chestsOpened: G.chestsOpened,
descents: (G.descents || 0) + 1, cards: (G.cards || []).slice(),
};
const ctx = Object.assign({}, runCtx, { stage: G.stage + 1 });
newRun(ctx, carry);
applyCards(); // re-apply run-scoped card effects to the freshly built player
mode = "playing"; paused = false;
closeBigMap(); hide("pause"); hide("lootSheet"); hide("bagPanel"); show("hud");
el("roundLabel").textContent = "Raid";
el("tip").style.display = "none";
refreshHud();
showBanner("⬇ Depth " + G.descents + " — haul ×" + depthMult().toFixed(2) + ". Nothing is banked yet.", "warn", 4200);
}
- [ ] Step 5: Add a no-op
applyCards()stub directly abovedescendFloor()so this task stands alone (Task 9 fills it in):
// run-scoped upgrade effects — filled in by the card system (Task 9); a no-op until then
function applyCards() { }
- [ ] Step 6: Verify syntax
Run: node --check game.js
Expected: no output
- [ ] Step 7: Commit
git add game.js
git commit -m "Dive: descendFloor + newRun carry bag (haul, wounds, kit survive a descent)"
Task 5: Route the portals — descend continues, safe exits bank and advance¶
Files:
- Modify: game.js:4079-4083 (board completion), game.js:5971-5995 (progression block), game.js:5937 (run value)
Interfaces:
- Consumes: descendFloor(), diveActive(), depthMult()
- [ ] Step 1: Route board completion. Replace the
if (ex.prog >= 1) {...}block:
if (ex.prog >= 1) {
// THE DIVE: the descend portal banks nothing and ends nothing — unless this is the boss floor,
// where there is nothing below it, so its portal banks like a safe exit AND clears the biome.
if (diveActive() && ex.kind === "progress" && G.stage < CFG.prog.stagesPerBiome) { descendFloor(); return; }
G.extracted = true; G.extractKind = ex.kind; // "progress" = advance a floor · "safe"/other = bank only (endRun)
endRun(true, ex.kind === "progress" ? "You descend deeper, haul in hand." : "You slip out with your haul — banked.");
return;
}
- [ ] Step 2: Apply the depth bonus to currency. In
endRun, the gold/blueprint banking must be multiplied. Replace therunValline and theif (win)currency lines. First, right afterconst all = safe ? items.concat(safe) : items;insert:
// THE DIVE: depth upgrades what you FIND (via the rarity ramp) and diving multiplies what you SELL it for.
// Currency only — an item can't be multiplied without invisibly re-rolling it.
const dMult = diveActive() ? depthMult() : 1;
const bankGold = Math.round(G.gold * dMult), bankBp = Math.round(G.blueprints * dMult);
Then change runVal to use them:
const runVal = Math.round(all.reduce((a, it) => a + lootValue(it), 0) * 1) + bankGold + bankBp * 25;
and inside bankItem, multiply the valuable's sale:
const bankItem = (it) => {
if (it.type === "valuable") save.gold += Math.round((it.sell || 0) * dMult);
else save.stash.push(it); // gear
};
and in the if (win) branch replace the two currency lines:
save.gold += bankGold;
save.blueprints += bankBp;
- [ ] Step 3: Move the checkpoint advance to the safe exit. Replace the progression block (
if (G.isRaid && !G.sandbox && ...)):
let progNote = "";
if (G.isRaid && !G.sandbox && save.biomes && save.biomes[G.biome]) {
const bs = save.biomes[G.biome], last = CFG.prog.stagesPerBiome;
const bossPortal = win && G.extractKind === "progress";
if (bossPortal && G.stage >= last) {
// boss floor cleared → biome done; next biome unlocks. Boss floor stays unlocked for replay.
bs.cleared = true; bs.stage = Math.max(bs.stage, last);
const nb = save.biomes[G.biome + 1];
if (nb && !nb.unlocked) {
nb.unlocked = true; nb.stage = Math.max(nb.stage || 1, 1); progNote = "★ Biome " + (G.biome + 2) + " unlocked!";
save.biome = G.biome + 1; save.floorSel = 1; // auto-select the newly opened biome's first floor
} else { progNote = "Biome cleared — replay any floor to grind."; save.floorSel = bs.stage; }
} else if (win && diveActive()) {
// THE DIVE: a floor is unlocked by CASHING OUT from it, not by touching its portal. Dying at depth
// keeps the checkpoint where the last successful bank left it — you lost the haul, never the ladder —
// and it stops the checkpoint outrunning the player's actual competence.
bs.stage = Math.max(bs.stage, G.stage);
save.floorSel = bs.stage;
progNote = "Floor " + G.stage + " secured — you can drop in here next dive.";
} else if (bossPortal) {
bs.stage = Math.max(bs.stage, G.stage + 1); // pre-dive rule: descend → next floor unlocked
save.floorSel = G.stage + 1;
progNote = "Floor " + (G.stage + 1) + " unlocked.";
} else if (win) {
progNote = "Loot banked — you held your ground (no descent)."; // pre-dive safe exit: no advance
}
// death: nothing changes — your highest unlocked floor and all banked gear persist.
}
- [ ] Step 4: Verify syntax
Run: node --check game.js
Expected: no output
-
[ ] Step 5: Browser check. Start Biome 1 Floor 1. Kill the Gatekeeper, board the descend portal. Expected: no result screen; the map regenerates as Floor 2; the bag count, gold and current HP are unchanged; the depth banner reads "Depth 1 — haul ×1.25". Then take a safe exit on Floor 2. Expected: result screen banks everything; the note reads "Floor 2 secured"; the lobby floor picker offers Floor 2.
-
[ ] Step 6: Commit
git add game.js
git commit -m "Dive: descend continues the run; safe exits bank + advance; depth bonus on currency"
Task 6: Safe exits on the boss floor¶
Files:
- Modify: game.js — genMap's extract placement (locate with the grep in Step 1)
Interfaces:
- Consumes: diveActive() is NOT used here — the boss floor should carry safe exits regardless, since the fix is good for the pre-dive game too
- [ ] Step 1: Find the extract placement rule
Run: grep -n 'kind: "progress"\|kind: "safe"\|extracts.push' game.js
Expected: the lines in genMap that build map.extracts
-
[ ] Step 2: Read the surrounding block and determine whether the boss floor (
stage >= CFG.prog.stagesPerBiome) is excluded from safe-exit placement. If safe exits are already placed on every floor, this task is a no-op — record that and skip to Step 5. -
[ ] Step 3: If the boss floor has no safe exits, remove the exclusion so it receives the same safe-exit count as any other floor, keeping its boss-gated progress portal alongside them. The design intent, in a comment at the change site:
// THE DIVE: the boss floor carries ordinary safe exits alongside its boss-gated portal, so walking away
// from the boss with a great haul is a legitimate way to finish a dive. The measured funnel's worst number
// by ~3x (floor 1-5, fail/complete 43.9) is a floor that offers no way out except through the boss.
- [ ] Step 4: Verify syntax
Run: node --check game.js
Expected: no output
-
[ ] Step 5: Browser check. Use the Tuning panel to start a Biome 1 Floor 5 run. Expected: at least one non-progress extraction pad exists on the map and can be boarded without killing the boss; boarding it banks the haul and does NOT mark the biome cleared.
-
[ ] Step 6: Commit
git add game.js
git commit -m "Dive: boss floor carries safe exits - the boss becomes optional"
Task 7: Scale the rival fat-haul threshold¶
Files:
- Modify: game.js:3694-3696
Interfaces:
- Consumes: CFG.rival.oppHaulPerDescent
- [ ] Step 1: Replace the
temptedexpression
// The fat-haul threshold GROWS with the dive: the bag never resets across floors, so a flat count would
// leave every Opportunist permanently triggered from ~floor 2 onward and turn the back half of a run
// into a rival gauntlet. Wounded-prey temptation is unchanged.
const fatHaul = CFG.rival.oppHaul + (G.descents || 0) * CFG.rival.oppHaulPerDescent;
const tempted = en.opportunist && (G.biome || 0) >= CFG.rival.oppFromBiome && seesP
&& (p.hp < p.maxhp * CFG.rival.oppHpFrac
|| (G.bag.length >= fatHaul && G.extracts.some(ex => !ex.closed && dist(p.x, p.y, ex.x, ex.y) < 300)));
- [ ] Step 2: Verify syntax
Run: node --check game.js
Expected: no output
- [ ] Step 3: Commit
git add game.js
git commit -m "Dive: Opportunist fat-haul threshold scales with descents"
Task 8: The at-risk HUD readout¶
Files:
- Modify: index.html:42-46 (the hud-bag block), style.css (one rule), game.js:5798-5810 (refreshHud)
Interfaces:
- Consumes: diveActive(), depthMult(), runHaulValue(G) (already exists at game.js:843)
- [ ] Step 1: Add the markup. In
index.html, inside<div class="hud-bag">, after the gold line:
<div class="atrisk hidden" id="atRisk">🎒 <b id="atRiskVal">0</b> at risk · <b id="atRiskDepth">⬇ 0 · ×1.00</b></div>
- [ ] Step 2: Add the style. Append to
style.css:
/* THE DIVE: the at-risk readout is the feature — the haul and the multiplier are the whole decision */
.hud-bag .atrisk { font-size: 11px; color: #ffd964; text-align: right; margin-top: 2px; white-space: nowrap; }
.hud-bag .atrisk.hidden { display: none; }
- [ ] Step 3: Drive it from
refreshHud. After theel("bagGold").textContent = G.gold;line:
// THE DIVE: the two numbers that ARE the decision — what you'd lose, and what carrying it deeper is worth
const ar = el("atRisk");
if (diveActive()) {
ar.classList.remove("hidden");
el("atRiskVal").textContent = Math.round(runHaulValue(G) * depthMult());
el("atRiskDepth").textContent = "⬇ " + (G.descents || 0) + " · ×" + depthMult().toFixed(2);
} else ar.classList.add("hidden");
- [ ] Step 4: Verify syntax
Run: node --check game.js
Expected: no output
-
[ ] Step 5: Browser check. Start a raid, pick up loot. Expected: the at-risk number rises as loot lands; after one descent it shows
⬇ 1 · ×1.25and the value jumps accordingly. -
[ ] Step 6: Commit
git add index.html style.css game.js
git commit -m "Dive: at-risk haul + depth multiplier HUD readout"
Task 9: The descend upgrade cards¶
Files:
- Modify: game.js (card table near the other data tables, applyCards(), descendFloor()), index.html (the card overlay), style.css
Interfaces:
- Consumes: CFG.dive.{cardCount, healCardHpFrac, cardHealFrac}, descendFloor()
- Produces: DIVE_CARDS (array), applyCards(), offerCards(onPick), G.cards (array of card ids)
- [ ] Step 1: Add the card table immediately before
function applyCards():
// THE DIVE — run-scoped upgrade cards. One card from each category is offered at every descent, so the
// choice reads identically every time: survive longer / kill faster / carry more out. Picks live on G and
// evaporate at bank or death — they never touch `save`. That boundary is what keeps the dive ONE structural
// bet instead of a second progression system to balance against the gear meta. Additive, repeatable, flat:
// no rarity tiers, no synergies. `apply(p)` runs against a freshly built player after every descent.
const DIVE_CARDS = [
// --- BODY: survive longer
{ id: "patch", cat: "body", icon: "❤", name: "Patch Up", desc: "Restore health now",
heal: true, apply(p) { p.hp = Math.min(p.maxhp, p.hp + Math.round(p.maxhp * CFG.dive.cardHealFrac)); } },
{ id: "hide", cat: "body", icon: "🛡", name: "Thick Hide", desc: "+20% max health",
apply(p) { const add = Math.round(p.maxhp * 0.20); p.maxhp += add; p.hp += add; } },
{ id: "blood", cat: "body", icon: "🩸", name: "Bloodletting", desc: "Flank crits heal you",
apply(p) { p.critHeal = (p.critHeal || 0) + 0.12; } },
// --- EDGE: kill faster
{ id: "sharp", cat: "edge", icon: "🗡", name: "Sharpened", desc: "+25% flank crit damage",
apply(p) { p.crit = Math.round((p.crit || 0) + 25); } },
{ id: "quick", cat: "edge", icon: "💨", name: "Quickstep", desc: "-20% dash cooldown",
apply(p) { p.dashCdMul = (p.dashCdMul || 1) * 0.8; } },
{ id: "steady", cat: "edge", icon: "🎯", name: "Steady Hands", desc: "+20% attack speed",
apply(p) { p.atkSpeedMul = (p.atkSpeedMul || 1) * 1.2; } },
// --- GREED: carry more out
{ id: "pocket", cat: "greed", icon: "🔒", name: "Deep Pockets", desc: "+1 Safe Pocket slot",
apply(p) { p.safeSlots = (p.safeSlots || 1) + 1; } },
{ id: "scav", cat: "greed", icon: "💰", name: "Scavenger", desc: "+10% depth bonus",
apply(p) { p.depthBonusAdd = (p.depthBonusAdd || 0) + 0.10; } },
{ id: "magnet", cat: "greed", icon: "🧲", name: "Wide Magnet", desc: "+40% pickup radius",
apply(p) { p.magnetMul = (p.magnetMul || 1) * 1.4; } },
];
const CARD_CATS = ["body", "edge", "greed"];
- [ ] Step 2: Implement
applyCards(), replacing the Task-4 stub:
// re-apply every card the run has picked to the (freshly rebuilt) player. Idempotent per floor because the
// player object is new each descent — never call it twice on the same player.
function applyCards() {
if (!G || !G.cards) return;
const p = G.player;
for (const id of G.cards) { const c = DIVE_CARDS.find(k => k.id === id); if (c) c.apply(p); }
}
- [ ] Step 3: Make the depth bonus honour Scavenger. Update
depthMult()from Task 2:
function depthMult() {
const add = (G.player && G.player.depthBonusAdd) || 0;
return 1 + (G.descents || 0) * (CFG.dive.depthBonusPerFloor + add);
}
- [ ] Step 4: Add the overlay markup to
index.html, directly before the closing</body>:
<div id="diveCards" class="overlay hidden">
<div class="dive-panel">
<div class="dive-head">
<div class="dive-depth" id="diveDepth">⬇ Depth 1</div>
<div class="dive-sub" id="diveSub">Nothing is banked yet.</div>
</div>
<div class="dive-cards" id="diveCardRow"></div>
</div>
</div>
- [ ] Step 5: Add the styles. Append to
style.css:
/* THE DIVE: the descent screen — the run's only breath, where haul, depth and health share a frame */
#diveCards { position: fixed; inset: 0; background: rgba(6,8,14,.92); display: flex; align-items: center;
justify-content: center; z-index: 60; }
#diveCards.hidden { display: none; }
.dive-panel { width: min(94vw, 460px); text-align: center; }
.dive-head { margin-bottom: 18px; }
.dive-depth { font-size: 26px; font-weight: 700; color: #ffd964; }
.dive-sub { font-size: 13px; color: #9fb0c8; margin-top: 4px; }
.dive-cards { display: flex; gap: 10px; justify-content: center; }
.dive-card { flex: 1; background: #141a26; border: 2px solid #2a3547; border-radius: 12px; padding: 14px 8px;
cursor: pointer; transition: transform .12s, border-color .12s; }
.dive-card:active { transform: scale(.96); }
.dive-card .ic { font-size: 26px; }
.dive-card .nm { font-size: 13px; font-weight: 700; margin-top: 6px; color: #eaf0f8; }
.dive-card .ds { font-size: 11px; color: #9fb0c8; margin-top: 3px; line-height: 1.3; }
.dive-card[data-cat="body"] { border-color: #4a7a5a; }
.dive-card[data-cat="edge"] { border-color: #7a4a4a; }
.dive-card[data-cat="greed"] { border-color: #7a6a3a; }
- [ ] Step 6: Implement
offerCardsdirectly abovedescendFloor():
// Offer one card per category and run `onPick` once the player chooses. The Body slot is FORCED to Patch Up
// when the player is badly hurt — without that rail, carrying wounds between floors produces exactly the
// death spiral that arrives at the boss floor unwinnable, which is the failure the measured funnel already
// shows at the boss wall. The run is paused while the panel is up.
function offerCards(onPick) {
const p = G.player, hurt = p.hp < p.maxhp * CFG.dive.healCardHpFrac;
const picks = CARD_CATS.slice(0, CFG.dive.cardCount).map(cat => {
if (cat === "body" && hurt) return DIVE_CARDS.find(c => c.id === "patch");
return pick(DIVE_CARDS.filter(c => c.cat === cat));
});
el("diveDepth").textContent = "⬇ Depth " + G.descents + " · ×" + depthMult().toFixed(2);
el("diveSub").textContent = "🎒 " + Math.round(runHaulValue(G) * depthMult()) + " at risk · "
+ Math.max(0, Math.round(p.hp)) + "/" + Math.round(p.maxhp) + " HP — nothing is banked yet.";
const row = el("diveCardRow");
row.innerHTML = picks.map(c =>
`<div class="dive-card" data-cat="${c.cat}" data-id="${c.id}">
<div class="ic">${c.icon}</div><div class="nm">${c.name}</div><div class="ds">${c.desc}</div>
</div>`).join("");
const take = (id) => {
const c = DIVE_CARDS.find(k => k.id === id); if (!c) return;
G.cards.push(id);
if (c.heal) c.apply(G.player); // instant-effect cards fire NOW; stat cards re-apply on the next floor
hide("diveCards"); paused = false; onPick();
};
row.querySelectorAll(".dive-card").forEach(node =>
node.addEventListener("click", () => take(node.dataset.id), { once: true }));
paused = true; show("diveCards");
}
- [ ] Step 7: Hook it into the descend. In
descendFloor(), wrap the rebuild so the cards come first. Replace the body ofdescendFloor()built in Task 4 with:
function descendFloor() {
G.descents = (G.descents || 0) + 1; // the depth is real the moment you commit to the portal
offerCards(() => {
const p = G.player;
const carry = {
bag: G.bag, safe: G.safe, gold: G.gold, blueprints: G.blueprints,
hp: p.hp, potions: p.potions, cells: p.cells, loadout: p.loadout, devKit: G.devKit,
kills: G.kills, chestsOpened: G.chestsOpened,
descents: G.descents, cards: (G.cards || []).slice(),
};
const ctx = Object.assign({}, runCtx, { stage: G.stage + 1 });
newRun(ctx, carry);
applyCards(); // re-apply run-scoped card effects to the freshly built player
mode = "playing"; paused = false;
closeBigMap(); hide("pause"); hide("lootSheet"); hide("bagPanel"); show("hud");
el("roundLabel").textContent = "Raid";
el("tip").style.display = "none";
refreshHud();
showBanner("⬇ Depth " + G.descents + " — haul ×" + depthMult().toFixed(2) + ". Nothing is banked yet.", "warn", 4200);
});
}
- [ ] Step 8: Verify syntax
Run: node --check game.js
Expected: no output
-
[ ] Step 9: Browser check. Descend once. Expected: three cards appear, one per category, colour-coded; the game is paused behind them; picking one closes the panel and builds the next floor. Descend at below 40% HP. Expected: the Body card is Patch Up, and taking it heals immediately.
-
[ ] Step 10: Commit
git add game.js index.html style.css
git commit -m "Dive: descend upgrade cards (body/edge/greed, run-scoped, forced heal rail)"
Task 10: Result screens and the retry path¶
Files:
- Modify: game.js:6030-6058 (result rendering)
Interfaces:
- Consumes: diveActive() — note it must be read BEFORE mode flips, so capture it at the top of endRun
- [ ] Step 1: Capture the dive state at the top of
endRun.diveActive()readsG, which stays valid throughendRun, but capture it once for clarity — right afterconst tut = G.tutorial;:
const wasDive = diveActive(), depth = G.descents || 0;
- [ ] Step 2: Rewrite the win summary line. Replace the
el("resultTotals").innerHTML = ...inside theif (win)branch:
el("resultTotals").innerHTML = wasDive
? `Dive: <b>${depth + 1}</b> floors · depth bonus <b>×${depthMult().toFixed(2)}</b> · banked <b>${runVal}</b> value · <b>${G.kills}</b> kills`
: `Banked: <b>${all.length + equippedFound.length}</b> items · <b>${runVal}</b> value · <b>${G.kills}</b> kills`;
- [ ] Step 3: Lead the death card with the loss. Replace the
el("resultTotals").innerHTML = ...inside theelsebranch:
el("resultTotals").innerHTML = wasDive
? `Lost at depth <b>${depth}</b>: <b>${lostBag.length + equippedFound.length}</b> items · <b>${runVal}</b> value — win it back.`
: `Lost this raid: <b>${lostBag.length + equippedFound.length}</b> items · <b>${runVal}</b> value · <b>${G.kills}</b> kills`;
- [ ] Step 4: Relabel the retry. Replace the
elsebranch of the button block:
else {
el("btnContinue").textContent = wasDive ? "⚔ Dive Again" : "⚔ Raid Again";
// THE DIVE: retry drops at the CHECKPOINT, not at the floor you died on — you lost the haul, never the ladder
const bs = save.biomes && save.biomes[G.biome];
const ctx = wasDive && bs ? Object.assign({}, runCtx, { stage: Math.min(bs.stage, CFG.prog.stagesPerBiome) }) : runCtx;
resultNext = () => startRun(ctx);
lobbyBtn.hidden = false;
}
- [ ] Step 5: Verify syntax
Run: node --check game.js
Expected: no output
-
[ ] Step 6: Browser check. Dive two floors, then die. Expected: the card reads "Lost at depth 2 … win it back"; the button says "Dive Again"; pressing it starts at the checkpoint floor, not floor 3.
-
[ ] Step 7: Commit
git add game.js
git commit -m "Dive: dive-summary and loss-led result cards, Dive Again retries at the checkpoint"
Task 11: Copy — floor picker, pad prompts, first-dive coach¶
Files:
- Modify: game.js:6113-6115 (renderMenu), game.js RAID_INTRO (locate in Step 2)
- [ ] Step 1: Relabel the floor picker. In
renderMenu, replace thebtnPlaytext for the post-tutorial branch:
el("btnPlay").textContent = (CFG.dive.enabled ? "▶ Dive in — Biome " : "▶ Biome ") + (b + 1) + " · Floor " + fl + "/" + CFG.prog.stagesPerBiome +
(keep whatever the existing line concatenates after this point unchanged)
- [ ] Step 2: Add the first-dive coach line. Find
RAID_INTRO:
Run: grep -n "RAID_INTRO" game.js
Append one line to the array describing the core choice:
"⬇ The Descent Portal takes your haul DEEPER — richer floors, bigger payout, but nothing banks until you take a SAFE exit. Only what you carry out is yours.",
- [ ] Step 3: Verify syntax
Run: node --check game.js
Expected: no output
- [ ] Step 4: Commit
git add game.js
git commit -m "Dive: floor-picker and first-raid coach copy for the descent choice"
Task 12: Fix the GDD contradiction and update PROJECT_CONTEXT¶
Files:
- Modify: docs/design/dive-structure/gdd.md, docs/design/dive-structure/gdd.ru.md, PROJECT_CONTEXT.md
- [ ] Step 1: Fix the EN contradiction. In
gdd.md, in The clock, re-purposed, replace the sentence ending "...until the only door left is the one that takes the haul deeper." with:
*pushes the player down*, progressively removing the cheap ways out until banking means a long walk back
through a hostile map while the descend portal sits right there. The reserve pad below is the one exception
that keeps banking always *possible*.
- [ ] Step 2: Fix the RU contradiction. In
gdd.ru.md, replace the matching sentence ending "...пока единственной дверью не останется та, что уносит добычу глубже." with:
*толкают игрока вниз*, постепенно убирая дешёвые пути наружу, пока зачисление не начнёт означать долгий
путь назад через враждебную карту, тогда как портал спуска находится прямо здесь. Резервная площадка ниже —
единственное исключение, которое сохраняет зачисление всегда *возможным*.
- [ ] Step 3: Record the implementation in
PROJECT_CONTEXT.md. In §8.5.1 (the biome ladder), append:
- **THE DIVE (`CFG.dive.enabled`, 2026-07-28):** a session is one continuous descent. The descend portal
banks nothing and ends nothing — the next floor generates and the haul, wounds, consumables, field-equipped
gear and run-scoped upgrade cards all carry. **Safe exits** bank and advance the checkpoint (a floor is
unlocked by cashing out *from* it). Death costs the haul only. `map.roundTime` is a **per-floor closure
schedule** with no timer death, the reserve safe pad never closes, and the boss floor carries safe exits so
the boss is optional. Currency banks at `depthMult()` = 1 + descents × `dive.depthBonusPerFloor`.
See `docs/design/dive-structure/`.
- [ ] Step 4: Commit
git add docs/design/dive-structure PROJECT_CONTEXT.md
git commit -m "Docs: fix the GDD reserve-pad contradiction; record the dive in PROJECT_CONTEXT"
Self-Review¶
Spec coverage. Exit inversion → T5. Carrying state → T4. Depth Bonus → T5 (+T9 Scavenger). Cards + heal rail → T9. Timer demotion → T3. Always-open reserve → T3. Boss-floor safe exits → T6. At-risk HUD → T8. Descend screen → T9. Result cards → T10. Rival threshold → T7. Checkpoint/floor-picker re-read → T5/T11. Tunables → T1. Guards (tutorial/sandbox never dive) → T2 via diveActive().
Known gaps, stated rather than hidden. Three card effects write fields (critHeal, dashCdMul, atkSpeedMul, safeSlots, magnetMul) that no existing system reads. Wiring them into combat, dash, the Safe Pocket and the magnet is deliberately not in this plan — the structural bet must be playable and judged first, and the GDD marks the whole card system as cuttable. Sharpened (p.crit) and Thick Hide (p.maxhp) and Patch Up work immediately against existing stats, so each category has at least one fully live card. Track the remaining five as a follow-up task after the first playtest.
Type consistency. newRun(ctx, carry) carry keys are produced only in descendFloor and consumed only in newRun — checked field-by-field. depthMult() is defined in T2 and redefined once in T9 Step 3; the T9 version is final. applyCards() is stubbed in T4 Step 5 and replaced in T9 Step 2.