# Run Loop Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Create a fight → reward → fight → die → restart loop so the game never dead-ends. **Architecture:** Add a run state layer above combat state that tracks the player's evolving deck, HP, and card rewards deck across multiple combats. After victory, show 3 cards from the rewards deck for the player to pick from (or skip). After defeat, restart everything fresh. Combat state is created from run state each fight. **Tech Stack:** Vanilla JS, ES modules, bun test, biome --- ### Task 1: Run state module **Files:** - Create: `src/run.js` - Test: `src/run.test.js` **Step 1: Write failing tests for createRunState** ```javascript import { describe, test, expect } from "bun:test"; import { createRunState } from "./run.js"; import { initCards } from "./cards.js"; describe("createRunState", () => { test("initializes ironclad run with starter deck", async () => { await initCards(); const run = createRunState("ironclad"); expect(run.character).toBe("ironclad"); expect(run.hp).toBe(11); expect(run.maxHp).toBe(11); expect(run.deck).toEqual([ "strike_r", "strike_r", "strike_r", "strike_r", "strike_r", "defend_r", "defend_r", "defend_r", "defend_r", "bash", ]); expect(run.combatCount).toBe(0); }); test("builds rewards deck from common + uncommon cards", async () => { await initCards(); const run = createRunState("ironclad"); expect(run.cardRewardsDeck.length).toBe(56); // should not contain starters, rares, or upgraded cards expect(run.cardRewardsDeck).not.toContain("strike_r"); expect(run.cardRewardsDeck).not.toContain("bash"); expect(run.cardRewardsDeck).not.toContain("barricade"); expect(run.cardRewardsDeck).not.toContain("anger+"); // should contain common and uncommon expect(run.cardRewardsDeck).toContain("anger"); expect(run.cardRewardsDeck).toContain("battle_trance"); }); test("rewards deck is shuffled (not alphabetical)", async () => { await initCards(); const run1 = createRunState("ironclad"); const run2 = createRunState("ironclad"); // extremely unlikely both are identical if shuffled const same = run1.cardRewardsDeck.every((c, i) => c === run2.cardRewardsDeck[i]); expect(same).toBe(false); }); }); ``` **Step 2: Run tests to verify they fail** Run: `bun test src/run.test.js` Expected: FAIL - module not found **Step 3: Write createRunState implementation** In `src/run.js`: ```javascript import { getStarterDeck, getCard } from "./cards.js"; import { shuffle } from "./state.js"; export function createRunState(character) { return { character, hp: 11, maxHp: 11, deck: [...getStarterDeck(character)], cardRewardsDeck: buildRewardsDeck(character), combatCount: 0, }; } function buildRewardsDeck(character) { const cards = []; // getCard returns undefined for unknown ids, so we need to iterate all cards // we need access to the card database - add a getAllCards export to cards.js for (const card of getAllCards()) { if ( card.character === character && (card.rarity === "common" || card.rarity === "uncommon") && !card.id.endsWith("+") ) { cards.push(card.id); } } return shuffle([...cards]); } ``` Note: this requires exporting `shuffle` from state.js and adding `getAllCards` to cards.js. See step 4. **Step 4: Export shuffle from state.js, add getAllCards to cards.js** In `src/state.js`, the `shuffle` function needs to be exported. Find the existing shuffle function and add `export` keyword. In `src/cards.js`, add: ```javascript export function getAllCards() { return Object.values(cardDb); } ``` **Step 5: Run tests to verify they pass** Run: `bun test src/run.test.js` Expected: PASS (3 tests) **Step 6: Commit** ``` Add run state module with createRunState and rewards deck builder ``` --- ### Task 2: Reveal and pick reward cards **Files:** - Modify: `src/run.js` - Test: `src/run.test.js` **Step 1: Write failing tests for revealRewards and pickReward** Add to `src/run.test.js`: ```javascript import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js"; describe("revealRewards", () => { test("reveals top 3 cards from rewards deck", async () => { await initCards(); const run = createRunState("ironclad"); const top3 = run.cardRewardsDeck.slice(0, 3); const result = revealRewards(run); expect(result.revealed).toEqual(top3); expect(result.cardRewardsDeck.length).toBe(53); }); test("reveals fewer if deck has < 3 cards", async () => { await initCards(); const run = createRunState("ironclad"); run.cardRewardsDeck = ["anger", "flex"]; const result = revealRewards(run); expect(result.revealed).toEqual(["anger", "flex"]); expect(result.cardRewardsDeck.length).toBe(0); }); }); describe("pickReward", () => { test("adds picked card to deck", async () => { await initCards(); const run = createRunState("ironclad"); const revealed = ["anger", "flex", "cleave"]; const result = pickReward(run, revealed, 0); expect(result.deck).toContain("anger"); expect(result.deck.length).toBe(11); // 10 starter + 1 }); test("does not return unpicked cards to rewards deck", async () => { await initCards(); const run = createRunState("ironclad"); // remove these from rewards deck so we can track them run.cardRewardsDeck = run.cardRewardsDeck.filter( (c) => !["anger", "flex", "cleave"].includes(c) ); const revealed = ["anger", "flex", "cleave"]; const result = pickReward(run, revealed, 0); // flex and cleave are gone (not in deck, not in rewards) expect(result.deck).not.toContain("flex"); expect(result.cardRewardsDeck).not.toContain("flex"); }); }); describe("skipRewards", () => { test("shuffles all revealed cards back into rewards deck", async () => { await initCards(); const run = createRunState("ironclad"); run.cardRewardsDeck = ["a", "b", "c"]; const revealed = ["x", "y", "z"]; const result = skipRewards(run, revealed); expect(result.cardRewardsDeck.length).toBe(6); expect(result.cardRewardsDeck).toContain("x"); expect(result.cardRewardsDeck).toContain("y"); expect(result.cardRewardsDeck).toContain("z"); }); }); ``` **Step 2: Run tests to verify they fail** Run: `bun test src/run.test.js` Expected: FAIL - functions not exported **Step 3: Implement revealRewards, pickReward, skipRewards** Add to `src/run.js`: ```javascript export function revealRewards(run) { const count = Math.min(3, run.cardRewardsDeck.length); const revealed = run.cardRewardsDeck.slice(0, count); const remaining = run.cardRewardsDeck.slice(count); return { ...run, revealed, cardRewardsDeck: remaining }; } export function pickReward(run, revealed, index) { const picked = revealed[index]; return { ...run, deck: [...run.deck, picked], }; } export function skipRewards(run, revealed) { return { ...run, cardRewardsDeck: shuffle([...run.cardRewardsDeck, ...revealed]), }; } ``` **Step 4: Run tests to verify they pass** Run: `bun test src/run.test.js` Expected: PASS (all tests) **Step 5: Commit** ``` Add reward reveal, pick, and skip functions ``` --- ### Task 3: Create combat from run state **Files:** - Modify: `src/state.js` - Test: `src/state.test.js` (or add to existing tests) **Step 1: Write failing tests for createCombatFromRun** Find existing state tests and add (or create `src/run.test.js` additions): ```javascript import { createCombatFromRun } from "./state.js"; import { initCards } from "./cards.js"; import { initEnemies } from "./enemies.js"; import { createRunState } from "./run.js"; describe("createCombatFromRun", () => { test("uses run deck instead of starter deck", async () => { await initCards(); await initEnemies(); const run = createRunState("ironclad"); run.deck = [...run.deck, "anger"]; // 11 cards const state = createCombatFromRun(run, "jaw_worm"); const allCards = [ ...state.players[0].drawPile, ...state.players[0].hand, ...state.players[0].discardPile, ]; expect(allCards.length).toBe(11); expect(allCards).toContain("anger"); }); test("uses run HP", async () => { await initCards(); await initEnemies(); const run = createRunState("ironclad"); run.hp = 7; const state = createCombatFromRun(run, "jaw_worm"); expect(state.players[0].hp).toBe(7); expect(state.players[0].maxHp).toBe(11); }); test("increments combatCount on run", async () => { await initCards(); await initEnemies(); const run = createRunState("ironclad"); expect(run.combatCount).toBe(0); const state = createCombatFromRun(run, "jaw_worm"); // combat state should carry the combat number expect(state.combat.combatCount).toBe(1); }); }); ``` **Step 2: Run tests to verify they fail** Run: `bun test src/state.test.js` (or whichever file) Expected: FAIL **Step 3: Implement createCombatFromRun** Add to `src/state.js`: ```javascript export function createCombatFromRun(run, enemyIdOrIds) { const enemyIds = Array.isArray(enemyIdOrIds) ? enemyIdOrIds : [enemyIdOrIds]; const enemies = enemyIds.map((id, i) => makeEnemy(id, i)); const player = { id: 0, character: run.character, hp: run.hp, maxHp: run.maxHp, energy: 3, maxEnergy: 3, block: 0, strength: 0, vulnerable: 0, weak: 0, drawPile: shuffle([...run.deck]), hand: [], discardPile: [], exhaustPile: [], powers: [], }; return { players: [player], enemies, combat: { turn: 1, phase: "player_turn", dieResult: null, selectedCard: null, log: [], playerCount: 1, activePlayerIndex: null, playersReady: [], combatCount: run.combatCount + 1, }, player, enemy: enemies[0], }; } ``` **Step 4: Run tests to verify they pass** Run: `bun test` Expected: PASS **Step 5: Commit** ``` Add createCombatFromRun to build combat state from run ``` --- ### Task 4: Wire the run loop in main.js **Files:** - Modify: `src/main.js` This task connects the run state, combat, rewards, and restart flow. No unit tests for this task (it's DOM wiring) - we'll verify by playing. **Step 1: Update init() to create run state** Replace the current init: ```javascript import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js"; import { createCombatFromRun } from "./state.js"; let state = null; let run = null; let revealed = null; async function init() { await Promise.all([initCards(), initEnemies()]); startNewRun(); } function startNewRun() { run = createRunState("ironclad"); startNextCombat(); } function startNextCombat() { run = { ...run, combatCount: run.combatCount + 1 }; state = createCombatFromRun(run, "jaw_worm"); state = startTurn(state); revealed = null; render(state); } ``` **Step 2: Update victory handling to show rewards phase** When combat ends in victory, transition to rewards instead of dead-ending. In the places where `checkCombatEnd` returns "victory": ```javascript const result = checkCombatEnd(state); if (result === "victory") { state = { ...state, combat: { ...state.combat, phase: "rewards" } }; const rewardResult = revealRewards(run); run = rewardResult; revealed = rewardResult.revealed; render(state, revealed); return; } if (result === "defeat") { state = { ...state, combat: { ...state.combat, phase: "ended", result: "defeat" } }; render(state); return; } ``` **Step 3: Update defeat handling to allow restart** Add click handler on overlay for restart: ```javascript document.getElementById("overlay").addEventListener("click", () => { if (state.combat.phase === "ended" && state.combat.result === "defeat") { startNewRun(); } }); ``` **Step 4: Commit** ``` Wire run loop in main.js with victory rewards and defeat restart ``` --- ### Task 5: Render rewards screen and handle pick/skip **Files:** - Modify: `src/render.js` - Modify: `src/main.js` - Modify: `index.html` **Step 1: Add skip button to HTML** In `index.html`, inside or near the overlay, add a skip button (can be dynamically shown): ```html