Skip to content

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 is node --check for 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.js DEFAULTS, gets a CONFIG_META blurb, and is read at the use-site as CFG.<group>.<key>.
  • Every dive rule is gated on diveActive(). With dive.enabled: false the 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 touch save.
  • 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.roundTime 180.

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 dive group to DEFAULTS, placed directly after the prog group 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 oppHaulPerDescent to the rival group, immediately after oppHaul.
      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.roundTime to 180. Find the roundTime: 330 line 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_META blurbs, 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's G = {...} literal. Find the line kills: 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 _finalSafe arming 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); and syncWeapon(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 the bag/kills lines:
    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() after startRun (before let 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 above descendFloor() 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 the runVal line and the if (win) currency lines. First, right after const 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.jsgenMap'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 tempted expression
      // 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 the el("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.25 and 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 offerCards directly above descendFloor():
// 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 of descendFloor() 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() reads G, which stays valid through endRun, but capture it once for clarity — right after const tut = G.tutorial;:
  const wasDive = diveActive(), depth = G.descents || 0;
  • [ ] Step 2: Rewrite the win summary line. Replace the el("resultTotals").innerHTML = ... inside the if (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 the else branch:
    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 else branch 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 the btnPlay text 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.