slaywithfriends/docs/plans/2026-02-23-single-combat-plan.md

40 KiB

Single Combat Encounter Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Playable single Ironclad combat encounter vs one enemy in the browser.

Architecture: Vanilla JS with ES modules, pure state functions tested with bun:test, HTML/CSS rendering, no build step. State drives rendering — all game logic is in testable pure functions, render function projects state to DOM.

Tech Stack: Bun (serve + test), Biome (lint/format, 4 spaces), vanilla JS, HTML/CSS

Note on card values: An agent is currently extracting board game card data from sheet images into data/cards.json. Starter deck values in this plan use board game scale. If the extracted data is available when you start, use those values. If not, use the values here and update later.


Task 1: Project scaffold

Files:

  • Create: package.json (via bun init)
  • Create: biome.json (via bunx biome init, set to 4 spaces)

Step 1: Initialize project

Run: cd /home/jtm/projects/slaywithfriends && bun init -y

Step 2: Add biome

Run: bun add -d @biomejs/biome && bunx biome init

Step 3: Configure biome for 4-space indent

Edit biome.json — set formatter indent to 4 spaces, add check script to package.json:

{
    "scripts": {
        "check": "bunx biome check --write && bun test",
        "dev": "bun run src/serve.js"
    }
}

biome.json should have:

{
    "formatter": {
        "indentStyle": "space",
        "indentWidth": 4
    }
}

Step 4: Create directory structure

mkdir -p src data

Step 5: Commit

Project scaffold with bun and biome

Task 2: Card data module

Files:

  • Create: data/starter-ironclad.json
  • Create: data/enemies.json
  • Create: src/cards.js
  • Test: src/cards.test.js

Step 1: Write starter deck JSON

data/starter-ironclad.json — the ironclad starter deck (board game values). Check if data/cards.json exists from the extraction agent. If it does, pull ironclad starter values from there. If not, use these values and confirm against the card sheet images in StS_BG_assets/Cards_Ironclad_Start.png:

{
    "strike_r": {
        "id": "strike_r",
        "name": "Strike",
        "cost": 1,
        "type": "attack",
        "effects": [{ "type": "hit", "value": 1 }],
        "keywords": [],
        "description": "Deal 1 damage."
    },
    "defend_r": {
        "id": "defend_r",
        "name": "Defend",
        "cost": 1,
        "type": "skill",
        "effects": [{ "type": "block", "value": 1 }],
        "keywords": [],
        "description": "Gain 1 Block."
    },
    "bash": {
        "id": "bash",
        "name": "Bash",
        "cost": 2,
        "type": "attack",
        "effects": [
            { "type": "hit", "value": 1 },
            { "type": "vulnerable", "value": 2 }
        ],
        "keywords": [],
        "description": "Deal 1 damage. Apply 2 Vulnerable."
    }
}

IMPORTANT: Read StS_BG_assets/Cards_Ironclad_Start.png to verify exact board game values before writing. The values above are estimates.

Step 2: Write enemy data JSON

data/enemies.json — one Act I enemy. Read StS_BG_assets/Enemies_Act1.png (or the first encounter sheet) to pick a simple enemy and get exact stats.

{
    "jaw_worm": {
        "id": "jaw_worm",
        "name": "Jaw Worm",
        "hp": 6,
        "actionType": "die",
        "actions": {
            "1": { "intent": "attack", "effects": [{ "type": "hit", "value": 2 }] },
            "2": { "intent": "attack", "effects": [{ "type": "hit", "value": 2 }] },
            "3": { "intent": "defend", "effects": [{ "type": "block", "value": 2 }] },
            "4": { "intent": "defend", "effects": [{ "type": "block", "value": 2 }] },
            "5": { "intent": "buff", "effects": [{ "type": "strength", "value": 1 }] },
            "6": { "intent": "buff", "effects": [{ "type": "strength", "value": 1 }] }
        }
    }
}

IMPORTANT: Verify against the actual board game enemy card image.

Step 3: Write failing test for cards module

src/cards.test.js:

import { describe, expect, test } from "bun:test";
import { getCard, getStarterDeck } from "./cards.js";

describe("cards", () => {
    test("getCard returns card by id", () => {
        const card = getCard("strike_r");
        expect(card.name).toBe("Strike");
        expect(card.cost).toBe(1);
        expect(card.type).toBe("attack");
        expect(card.effects[0].type).toBe("hit");
    });

    test("getCard returns undefined for unknown id", () => {
        expect(getCard("nonexistent")).toBeUndefined();
    });

    test("getStarterDeck returns 10 card ids for ironclad", () => {
        const deck = getStarterDeck("ironclad");
        expect(deck).toHaveLength(10);
        expect(deck.filter((id) => id === "strike_r")).toHaveLength(5);
        expect(deck.filter((id) => id === "defend_r")).toHaveLength(4);
        expect(deck.filter((id) => id === "bash")).toHaveLength(1);
    });
});

Step 4: Run test to verify it fails

Run: bun test src/cards.test.js Expected: FAIL — module not found

Step 5: Implement cards module

src/cards.js:

import starterIronclad from "../data/starter-ironclad.json";

const cardDb = { ...starterIronclad };

export function getCard(id) {
    return cardDb[id];
}

export function getStarterDeck(character) {
    if (character === "ironclad") {
        return [
            ...Array(5).fill("strike_r"),
            ...Array(4).fill("defend_r"),
            "bash",
        ];
    }
    return [];
}

Step 6: Run test to verify it passes

Run: bun test src/cards.test.js Expected: PASS

Step 7: Commit

Add card data module with ironclad starter deck

Task 3: State module

Files:

  • Create: src/state.js
  • Test: src/state.test.js

Step 1: Write failing tests

src/state.test.js:

import { describe, expect, test } from "bun:test";
import { createCombatState, drawCards, playCard, endTurn } from "./state.js";

describe("createCombatState", () => {
    test("creates initial state with shuffled deck and correct values", () => {
        const state = createCombatState("ironclad", "jaw_worm");
        expect(state.player.hp).toBe(11);
        expect(state.player.maxHp).toBe(11);
        expect(state.player.energy).toBe(3);
        expect(state.player.block).toBe(0);
        expect(state.player.strength).toBe(0);
        expect(state.player.hand).toHaveLength(0);
        expect(state.player.drawPile).toHaveLength(10);
        expect(state.enemy.name).toBe("Jaw Worm");
        expect(state.enemy.hp).toBeGreaterThan(0);
        expect(state.combat.turn).toBe(1);
        expect(state.combat.phase).toBe("player_turn");
    });
});

describe("drawCards", () => {
    test("moves cards from draw pile to hand", () => {
        const state = createCombatState("ironclad", "jaw_worm");
        const next = drawCards(state, 5);
        expect(next.player.hand).toHaveLength(5);
        expect(next.player.drawPile).toHaveLength(5);
    });

    test("shuffles discard into draw when draw pile runs out", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        // move all but 2 cards to discard
        state = {
            ...state,
            player: {
                ...state.player,
                drawPile: state.player.drawPile.slice(0, 2),
                discardPile: state.player.drawPile.slice(2),
            },
        };
        const next = drawCards(state, 5);
        expect(next.player.hand).toHaveLength(5);
        expect(next.player.discardPile).toHaveLength(0);
    });
});

describe("playCard", () => {
    test("deducts energy and moves card to discard", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = drawCards(state, 5);
        const cardIndex = state.player.hand.indexOf("strike_r");
        const next = playCard(state, cardIndex);
        expect(next.player.energy).toBe(2);
        expect(next.player.hand).toHaveLength(4);
    });

    test("returns null if not enough energy", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = drawCards(state, 5);
        state = { ...state, player: { ...state.player, energy: 0 } };
        const cardIndex = state.player.hand.indexOf("strike_r");
        const result = playCard(state, cardIndex);
        expect(result).toBeNull();
    });
});

describe("endTurn", () => {
    test("discards hand and switches to enemy phase", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = drawCards(state, 5);
        const next = endTurn(state);
        expect(next.player.hand).toHaveLength(0);
        expect(next.player.discardPile.length).toBeGreaterThan(0);
        expect(next.combat.phase).toBe("enemy_turn");
    });
});

Step 2: Run test to verify it fails

Run: bun test src/state.test.js Expected: FAIL

Step 3: Implement state module

src/state.js:

import { getCard, getStarterDeck } from "./cards.js";
import { resolveEffects } from "./effects.js";
import { getEnemy } from "./enemies.js";

export function createCombatState(character, enemyId) {
    const enemy = getEnemy(enemyId);
    return {
        player: {
            hp: 11,
            maxHp: 11,
            energy: 3,
            maxEnergy: 3,
            block: 0,
            strength: 0,
            vulnerable: 0,
            weak: 0,
            drawPile: shuffle([...getStarterDeck(character)]),
            hand: [],
            discardPile: [],
            exhaustPile: [],
            powers: [],
        },
        enemy: {
            id: enemy.id,
            name: enemy.name,
            hp: enemy.hp,
            maxHp: enemy.hp,
            block: 0,
            strength: 0,
            vulnerable: 0,
            weak: 0,
            actionType: enemy.actionType,
            actions: enemy.actions,
            actionTrack: enemy.actionTrack || null,
            trackPosition: 0,
        },
        combat: {
            turn: 1,
            phase: "player_turn",
            dieResult: null,
            selectedCard: null,
            log: [],
        },
    };
}

export function drawCards(state, count) {
    let drawPile = [...state.player.drawPile];
    let discardPile = [...state.player.discardPile];
    const hand = [...state.player.hand];

    for (let i = 0; i < count; i++) {
        if (drawPile.length === 0) {
            drawPile = shuffle(discardPile);
            discardPile = [];
        }
        if (drawPile.length > 0) {
            hand.push(drawPile.pop());
        }
    }

    return {
        ...state,
        player: { ...state.player, drawPile, hand, discardPile },
    };
}

export function playCard(state, handIndex) {
    const cardId = state.player.hand[handIndex];
    const card = getCard(cardId);
    if (state.player.energy < card.cost) return null;

    const hand = [...state.player.hand];
    hand.splice(handIndex, 1);
    const discardPile = [...state.player.discardPile, cardId];
    const energy = state.player.energy - card.cost;

    let next = {
        ...state,
        player: { ...state.player, hand, discardPile, energy },
    };

    next = resolveEffects(next, card.effects, "player", "enemy");
    return next;
}

export function endTurn(state) {
    return {
        ...state,
        player: {
            ...state.player,
            hand: [],
            discardPile: [...state.player.discardPile, ...state.player.hand],
        },
        combat: { ...state.combat, phase: "enemy_turn" },
    };
}

function shuffle(arr) {
    const a = [...arr];
    for (let i = a.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [a[i], a[j]] = [a[j], a[i]];
    }
    return a;
}

Note: this depends on effects.js and enemies.js — those are the next two tasks. For this task to pass tests, you may need to create stub versions first:

Stub src/effects.js:

export function resolveEffects(state) {
    return state;
}

Stub src/enemies.js (and create data/enemies.json from Task 2):

import enemyDb from "../data/enemies.json";

export function getEnemy(id) {
    return enemyDb[id];
}

Step 4: Run test to verify it passes

Run: bun test src/state.test.js Expected: PASS

Step 5: Commit

Add state module with combat init, draw, play, end turn

Task 4: Effect resolver

Files:

  • Create: src/effects.js (replace stub)
  • Test: src/effects.test.js

This is the most test-heavy module. Each effect type and modifier interaction needs coverage.

Step 1: Write failing tests

src/effects.test.js:

import { describe, expect, test } from "bun:test";
import { resolveEffects, calculateHitDamage } from "./effects.js";
import { createCombatState } from "./state.js";

function makeState(overrides = {}) {
    const base = createCombatState("ironclad", "jaw_worm");
    return {
        ...base,
        player: { ...base.player, ...overrides.player },
        enemy: { ...base.enemy, ...overrides.enemy },
    };
}

describe("calculateHitDamage", () => {
    test("base damage with no modifiers", () => {
        expect(calculateHitDamage(1, 0, false, false)).toBe(1);
    });

    test("strength adds to damage", () => {
        expect(calculateHitDamage(1, 2, false, false)).toBe(3);
    });

    test("vulnerable doubles damage", () => {
        expect(calculateHitDamage(2, 0, true, false)).toBe(4);
    });

    test("weak reduces damage by 1", () => {
        expect(calculateHitDamage(3, 0, false, true)).toBe(2);
    });

    test("weak and vulnerable cancel out", () => {
        expect(calculateHitDamage(2, 0, true, true)).toBe(2);
    });

    test("strength applied before vulnerable doubling", () => {
        expect(calculateHitDamage(1, 1, true, false)).toBe(4);
    });

    test("damage cannot go below 0", () => {
        expect(calculateHitDamage(0, 0, false, true)).toBe(0);
    });
});

describe("resolveEffects - hit", () => {
    test("hit reduces enemy hp", () => {
        const state = makeState();
        const effects = [{ type: "hit", value: 1 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.hp).toBe(state.enemy.hp - 1);
    });

    test("hit absorbed by block first", () => {
        const state = makeState({ enemy: { block: 3 } });
        const effects = [{ type: "hit", value: 2 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.block).toBe(1);
        expect(next.enemy.hp).toBe(state.enemy.hp);
    });

    test("hit overflow past block damages hp", () => {
        const state = makeState({ enemy: { block: 1 } });
        const effects = [{ type: "hit", value: 3 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.block).toBe(0);
        expect(next.enemy.hp).toBe(state.enemy.hp - 2);
    });

    test("vulnerable consumed after hit", () => {
        const state = makeState({ enemy: { vulnerable: 2 } });
        const effects = [{ type: "hit", value: 1 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.vulnerable).toBe(1);
    });
});

describe("resolveEffects - block", () => {
    test("block adds to player block", () => {
        const state = makeState();
        const effects = [{ type: "block", value: 2 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.player.block).toBe(2);
    });

    test("block caps at 20 for player", () => {
        const state = makeState({ player: { block: 19 } });
        const effects = [{ type: "block", value: 5 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.player.block).toBe(20);
    });
});

describe("resolveEffects - status effects", () => {
    test("vulnerable applies to target", () => {
        const state = makeState();
        const effects = [{ type: "vulnerable", value: 2 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.vulnerable).toBe(2);
    });

    test("vulnerable caps at 3", () => {
        const state = makeState({ enemy: { vulnerable: 2 } });
        const effects = [{ type: "vulnerable", value: 3 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.vulnerable).toBe(3);
    });

    test("weak applies to target", () => {
        const state = makeState();
        const effects = [{ type: "weak", value: 1 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.enemy.weak).toBe(1);
    });

    test("strength applies to source", () => {
        const state = makeState();
        const effects = [{ type: "strength", value: 1 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.player.strength).toBe(1);
    });

    test("strength caps at 8", () => {
        const state = makeState({ player: { strength: 7 } });
        const effects = [{ type: "strength", value: 3 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.player.strength).toBe(8);
    });
});

describe("resolveEffects - draw", () => {
    test("draw effect draws cards", () => {
        const state = makeState();
        // put all cards in draw pile
        const effects = [{ type: "draw", value: 2 }];
        const next = resolveEffects(state, effects, "player", "enemy");
        expect(next.player.hand).toHaveLength(2);
        expect(next.player.drawPile).toHaveLength(8);
    });
});

Step 2: Run test to verify it fails

Run: bun test src/effects.test.js Expected: FAIL

Step 3: Implement effects module

Replace src/effects.js:

import { drawCards } from "./state.js";

export function calculateHitDamage(base, strength, targetVulnerable, attackerWeak) {
    if (targetVulnerable && attackerWeak) {
        return base + strength;
    }
    let damage = base + strength;
    if (targetVulnerable) {
        damage *= 2;
    }
    if (attackerWeak) {
        damage -= 1;
    }
    return Math.max(0, damage);
}

export function resolveEffects(state, effects, source, target) {
    let current = state;
    for (const effect of effects) {
        current = resolveSingleEffect(current, effect, source, target);
    }
    return current;
}

function resolveSingleEffect(state, effect, source, target) {
    switch (effect.type) {
        case "hit":
            return resolveHit(state, effect.value, source, target);
        case "block":
            return resolveBlock(state, effect.value, source);
        case "vulnerable":
            return applyStatus(state, target, "vulnerable", effect.value, 3);
        case "weak":
            return applyStatus(state, target, "weak", effect.value, 3);
        case "strength":
            return applyStatus(state, source, "strength", effect.value, 8);
        case "draw":
            return drawCards(state, effect.value);
        case "lose_hp":
            return directDamage(state, target, effect.value);
        default:
            console.debug(`unhandled effect type: ${effect.type}`);
            return state;
    }
}

function resolveHit(state, baseValue, source, target) {
    const attacker = state[source];
    const defender = state[target];

    const damage = calculateHitDamage(
        baseValue,
        attacker.strength,
        defender.vulnerable > 0,
        attacker.weak > 0,
    );

    let block = defender.block;
    let hp = defender.hp;
    let remaining = damage;

    if (block > 0) {
        const absorbed = Math.min(block, remaining);
        block -= absorbed;
        remaining -= absorbed;
    }
    hp = Math.max(0, hp - remaining);

    // consume 1 vulnerable from defender
    const vulnerable = Math.max(0, defender.vulnerable - 1);

    return {
        ...state,
        [target]: { ...defender, hp, block, vulnerable },
    };
}

function resolveBlock(state, value, source) {
    const entity = state[source];
    const maxBlock = source === "player" ? 20 : 999;
    const block = Math.min(entity.block + value, maxBlock);
    return {
        ...state,
        [source]: { ...entity, block },
    };
}

function applyStatus(state, target, status, value, max) {
    const entity = state[target];
    const current = entity[status] || 0;
    return {
        ...state,
        [target]: { ...entity, [status]: Math.min(current + value, max) },
    };
}

function directDamage(state, target, value) {
    const entity = state[target];
    return {
        ...state,
        [target]: { ...entity, hp: Math.max(0, entity.hp - value) },
    };
}

Step 4: Run test to verify it passes

Run: bun test src/effects.test.js Expected: PASS

Step 5: Commit

Add effect resolver with hit, block, status, draw

Task 5: Die and enemy AI

Files:

  • Create: src/die.js
  • Modify: src/enemies.js (replace stub)
  • Test: src/die.test.js
  • Test: src/enemies.test.js

Step 1: Write failing tests

src/die.test.js:

import { describe, expect, test } from "bun:test";
import { rollDie } from "./die.js";

describe("rollDie", () => {
    test("returns a number between 1 and 6", () => {
        for (let i = 0; i < 100; i++) {
            const result = rollDie();
            expect(result).toBeGreaterThanOrEqual(1);
            expect(result).toBeLessThanOrEqual(6);
        }
    });
});

src/enemies.test.js:

import { describe, expect, test } from "bun:test";
import { getEnemy, resolveEnemyAction } from "./enemies.js";

describe("getEnemy", () => {
    test("returns enemy by id", () => {
        const enemy = getEnemy("jaw_worm");
        expect(enemy.name).toBe("Jaw Worm");
        expect(enemy.hp).toBeGreaterThan(0);
    });
});

describe("resolveEnemyAction", () => {
    test("die action returns effects for given roll", () => {
        const enemy = getEnemy("jaw_worm");
        const action = resolveEnemyAction(enemy, 1, 0);
        expect(action.effects).toBeDefined();
        expect(action.effects.length).toBeGreaterThan(0);
    });
});

Step 2: Run test to verify it fails

Run: bun test src/die.test.js src/enemies.test.js Expected: FAIL

Step 3: Implement

src/die.js:

export function rollDie() {
    return Math.floor(Math.random() * 6) + 1;
}

src/enemies.js (replace stub):

import enemyDb from "../data/enemies.json";

export function getEnemy(id) {
    return enemyDb[id];
}

export function resolveEnemyAction(enemy, dieResult, trackPosition) {
    if (enemy.actionType === "single") {
        return enemy.actions["1"];
    }
    if (enemy.actionType === "die") {
        return enemy.actions[String(dieResult)];
    }
    if (enemy.actionType === "cube") {
        const track = enemy.actionTrack;
        const pos = Math.min(trackPosition, track.length - 1);
        return track[pos];
    }
    return { intent: "unknown", effects: [] };
}

Step 4: Run tests

Run: bun test src/die.test.js src/enemies.test.js Expected: PASS

Step 5: Commit

Add die roll and enemy action resolver

Task 6: Combat orchestration

Files:

  • Create: src/combat.js
  • Test: src/combat.test.js

This module sequences turns. It uses state.js and effects.js but adds turn structure.

Step 1: Write failing tests

src/combat.test.js:

import { describe, expect, test } from "bun:test";
import { startTurn, resolveEnemyTurn, checkCombatEnd } from "./combat.js";
import { createCombatState } from "./state.js";

describe("startTurn", () => {
    test("resets energy and block, draws 5 cards", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = {
            ...state,
            player: { ...state.player, energy: 0, block: 5 },
        };
        const next = startTurn(state);
        expect(next.player.energy).toBe(3);
        expect(next.player.block).toBe(0);
        expect(next.player.hand).toHaveLength(5);
        expect(next.combat.dieResult).toBeGreaterThanOrEqual(1);
        expect(next.combat.dieResult).toBeLessThanOrEqual(6);
    });
});

describe("resolveEnemyTurn", () => {
    test("enemy attacks reduce player hp or block", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        // force a die result that maps to an attack
        state = { ...state, combat: { ...state.combat, dieResult: 1 } };
        const before = state.player.hp + state.player.block;
        const next = resolveEnemyTurn(state);
        const after = next.player.hp + next.player.block;
        // enemy should have done something
        expect(next.combat.phase).toBe("player_turn");
        expect(next.combat.turn).toBe(state.combat.turn + 1);
    });

    test("enemy block resets before acting", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = {
            ...state,
            enemy: { ...state.enemy, block: 5 },
            combat: { ...state.combat, dieResult: 1 },
        };
        const next = resolveEnemyTurn(state);
        // block should have been cleared before action
        // (enemy might regain some depending on action)
    });
});

describe("checkCombatEnd", () => {
    test("returns 'victory' when enemy hp is 0", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = { ...state, enemy: { ...state.enemy, hp: 0 } };
        expect(checkCombatEnd(state)).toBe("victory");
    });

    test("returns 'defeat' when player hp is 0", () => {
        let state = createCombatState("ironclad", "jaw_worm");
        state = { ...state, player: { ...state.player, hp: 0 } };
        expect(checkCombatEnd(state)).toBe("defeat");
    });

    test("returns null when combat continues", () => {
        const state = createCombatState("ironclad", "jaw_worm");
        expect(checkCombatEnd(state)).toBeNull();
    });
});

Step 2: Run test to verify it fails

Run: bun test src/combat.test.js Expected: FAIL

Step 3: Implement combat module

src/combat.js:

import { drawCards } from "./state.js";
import { resolveEffects } from "./effects.js";
import { resolveEnemyAction } from "./enemies.js";
import { rollDie } from "./die.js";

export function startTurn(state) {
    const dieResult = rollDie();
    let next = {
        ...state,
        player: {
            ...state.player,
            energy: state.player.maxEnergy,
            block: 0,
        },
        combat: {
            ...state.combat,
            phase: "player_turn",
            dieResult,
            selectedCard: null,
        },
    };
    next = drawCards(next, 5);
    return next;
}

export function resolveEnemyTurn(state) {
    // clear enemy block
    let next = {
        ...state,
        enemy: { ...state.enemy, block: 0 },
    };

    // resolve enemy action
    const action = resolveEnemyAction(
        next.enemy,
        next.combat.dieResult,
        next.enemy.trackPosition,
    );

    if (action && action.effects) {
        next = resolveEffects(next, action.effects, "enemy", "player");
    }

    // advance cube track if applicable
    let trackPosition = next.enemy.trackPosition;
    if (next.enemy.actionType === "cube") {
        trackPosition = Math.min(
            trackPosition + 1,
            (next.enemy.actionTrack || []).length - 1,
        );
    }

    return {
        ...next,
        enemy: { ...next.enemy, trackPosition },
        combat: {
            ...next.combat,
            phase: "player_turn",
            turn: next.combat.turn + 1,
        },
    };
}

export function checkCombatEnd(state) {
    if (state.enemy.hp <= 0) return "victory";
    if (state.player.hp <= 0) return "defeat";
    return null;
}

Step 4: Run tests

Run: bun test src/combat.test.js Expected: PASS

Step 5: Commit

Add combat orchestration with turn flow and win/loss check

Task 7: Run all tests, biome check

Step 1: Run full test suite

Run: bun test Expected: all tests pass

Step 2: Run biome check

Run: bunx biome check --write Expected: clean or auto-fixed

Step 3: Commit any formatting fixes

Format with biome

Task 8: HTML shell and dev server

Files:

  • Create: index.html
  • Create: style.css
  • Create: src/serve.js

Step 1: Create bun dev server

src/serve.js:

const server = Bun.serve({
    port: 3000,
    async fetch(req) {
        const url = new URL(req.url);
        let path = url.pathname === "/" ? "/index.html" : url.pathname;
        const file = Bun.file(`.${path}`);
        if (await file.exists()) {
            return new Response(file);
        }
        return new Response("not found", { status: 404 });
    },
});
console.debug(`dev server: http://localhost:${server.port}`);

Note: check with ports check 3000 first. If 3000 is taken, use ports next to find an available one.

Step 2: Create index.html

index.html — bare structure with the three zones. No styling yet, just semantic HTML:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>slay with friends</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id="game">
        <section id="enemy-zone">
            <div id="enemy-info">
                <span id="enemy-name"></span>
                <div id="enemy-hp-bar"><span id="enemy-hp"></span></div>
                <span id="enemy-block"></span>
                <div id="enemy-status"></div>
                <div id="enemy-intent"></div>
            </div>
        </section>

        <section id="info-bar">
            <div id="energy"></div>
            <div id="player-hp-bar"><span id="player-hp"></span></div>
            <div id="player-block"></div>
            <div id="player-strength"></div>
            <div id="pile-counts">
                <button id="draw-pile-btn">draw: <span id="draw-count">0</span></button>
                <button id="discard-pile-btn">discard: <span id="discard-count">0</span></button>
            </div>
            <button id="end-turn-btn">end turn</button>
        </section>

        <section id="hand"></section>
    </div>

    <div id="overlay" hidden></div>

    <script type="module" src="src/main.js"></script>
</body>
</html>

Step 3: Create style.css

style.css — minimal layout only, browser defaults for aesthetics:

* { margin: 0; padding: 0; box-sizing: border-box; }

#game {
    display: flex;
    flex-direction: column;
    height: 100dvh;
    max-width: 500px;
    margin: 0 auto;
}

#enemy-zone {
    flex: 4;
    display: flex;
    align-items: center;
    justify-content: center;
}

#info-bar {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 4px 8px;
    border-top: 1px solid #ccc;
    border-bottom: 1px solid #ccc;
}

#hand {
    flex: 3;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 8px;
    overflow-x: auto;
}

#hand .card {
    width: 80px;
    cursor: pointer;
    transition: transform 0.15s;
}

#hand .card.selected {
    transform: translateY(-20px);
}

#hand .card.no-energy {
    opacity: 0.5;
}

#overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.7);
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-size: 2rem;
}

#overlay[hidden] { display: none; }

Step 4: Verify server starts and page loads

Run: bun run src/serve.js Open browser to http://localhost:3000 — should see empty layout structure.

Step 5: Commit

Add HTML shell, CSS layout, and dev server

Task 9: Render module

Files:

  • Create: src/render.js

No unit tests for render — it's DOM manipulation. We verify visually and in the integration task.

Step 1: Implement render function

src/render.js — takes state, updates DOM. Reads card data for images and names.

import { getCard } from "./cards.js";
import { resolveEnemyAction } from "./enemies.js";

export function render(state) {
    renderEnemy(state);
    renderInfoBar(state);
    renderHand(state);
    renderOverlay(state);
}

function renderEnemy(state) {
    const { enemy, combat } = state;
    document.getElementById("enemy-name").textContent = enemy.name;
    document.getElementById("enemy-hp").textContent = `${enemy.hp}/${enemy.maxHp}`;
    document.getElementById("enemy-hp").style.width =
        `${(enemy.hp / enemy.maxHp) * 100}%`;
    document.getElementById("enemy-block").textContent =
        enemy.block > 0 ? `block: ${enemy.block}` : "";

    // status tokens
    const statusEl = document.getElementById("enemy-status");
    const tokens = [];
    if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`);
    if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`);
    if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`);
    statusEl.textContent = tokens.join(" | ");

    // intent
    const intentEl = document.getElementById("enemy-intent");
    if (combat.dieResult && combat.phase === "player_turn") {
        const action = resolveEnemyAction(
            enemy,
            combat.dieResult,
            enemy.trackPosition,
        );
        if (action) {
            intentEl.textContent = formatIntent(action);
        }
    } else {
        intentEl.textContent = "";
    }
}

function formatIntent(action) {
    if (!action || !action.effects) return "?";
    const parts = action.effects.map((e) => {
        if (e.type === "hit") return `attack ${e.value}`;
        if (e.type === "block") return `block ${e.value}`;
        if (e.type === "strength") return `str +${e.value}`;
        return e.type;
    });
    return parts.join(", ");
}

function renderInfoBar(state) {
    const { player } = state;
    document.getElementById("energy").textContent =
        `energy: ${player.energy}/${player.maxEnergy}`;
    document.getElementById("player-hp").textContent =
        `${player.hp}/${player.maxHp}`;
    document.getElementById("player-hp").style.width =
        `${(player.hp / player.maxHp) * 100}%`;
    document.getElementById("player-block").textContent =
        player.block > 0 ? `block: ${player.block}` : "";
    document.getElementById("player-strength").textContent =
        player.strength > 0 ? `str: ${player.strength}` : "";
    document.getElementById("draw-count").textContent = player.drawPile.length;
    document.getElementById("discard-count").textContent =
        player.discardPile.length;
}

function renderHand(state) {
    const handEl = document.getElementById("hand");
    const { player, combat } = state;

    handEl.innerHTML = "";
    player.hand.forEach((cardId, index) => {
        const card = getCard(cardId);
        const img = document.createElement("img");
        img.src = card.image || "";
        img.alt = `${card.name} (${card.cost})`;
        img.title = card.description;
        img.className = "card";
        img.dataset.index = index;

        if (index === combat.selectedCard) {
            img.classList.add("selected");
        }
        if (player.energy < card.cost) {
            img.classList.add("no-energy");
        }

        handEl.appendChild(img);
    });
}

function renderOverlay(state) {
    const overlay = document.getElementById("overlay");
    const result = state.combat.phase === "ended" ? state.combat.result : null;
    if (result === "victory") {
        overlay.hidden = false;
        overlay.textContent = "victory";
    } else if (result === "defeat") {
        overlay.hidden = false;
        overlay.textContent = "defeat";
    } else {
        overlay.hidden = true;
    }
}

Step 2: Commit

Add render module for state-to-DOM projection

Task 10: Main module — wire it all together

Files:

  • Create: src/main.js

Step 1: Implement main.js

src/main.js — initializes state, binds events, runs the game loop:

import { createCombatState, playCard, endTurn } from "./state.js";
import { startTurn, resolveEnemyTurn, checkCombatEnd } from "./combat.js";
import { render } from "./render.js";

let state = null;

function init() {
    state = createCombatState("ironclad", "jaw_worm");
    state = startTurn(state);
    render(state);
    bindEvents();
}

function bindEvents() {
    // card selection (two-tap)
    document.getElementById("hand").addEventListener("click", (e) => {
        const card = e.target.closest(".card");
        if (!card || state.combat.phase !== "player_turn") return;
        const index = Number(card.dataset.index);

        if (state.combat.selectedCard === index) {
            // deselect
            state = { ...state, combat: { ...state.combat, selectedCard: null } };
            render(state);
            return;
        }

        // select card
        state = { ...state, combat: { ...state.combat, selectedCard: index } };
        render(state);
    });

    // target enemy (second tap)
    document.getElementById("enemy-zone").addEventListener("click", () => {
        if (state.combat.selectedCard === null) return;
        if (state.combat.phase !== "player_turn") return;

        const result = playCard(state, state.combat.selectedCard);
        if (result === null) {
            // not enough energy — shake animation would go here
            state = { ...state, combat: { ...state.combat, selectedCard: null } };
            render(state);
            return;
        }

        state = { ...result, combat: { ...result.combat, selectedCard: null } };

        // check if enemy died
        const end = checkCombatEnd(state);
        if (end) {
            state = {
                ...state,
                combat: { ...state.combat, phase: "ended", result: end },
            };
            render(state);
            return;
        }

        render(state);
    });

    // end turn
    document.getElementById("end-turn-btn").addEventListener("click", async () => {
        if (state.combat.phase !== "player_turn") return;

        state = endTurn(state);
        render(state);

        // enemy turn with pause
        await delay(800);
        state = resolveEnemyTurn(state);

        const end = checkCombatEnd(state);
        if (end) {
            state = {
                ...state,
                combat: { ...state.combat, phase: "ended", result: end },
            };
            render(state);
            return;
        }

        // next player turn
        state = startTurn(state);
        render(state);
    });
}

function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

init();

Step 2: Start dev server and test manually

Run: bun run dev Open browser. Should see:

  • 5 cards in hand
  • enemy with HP
  • enemy intent displayed
  • can select a card, tap enemy to attack
  • can end turn, enemy attacks back
  • game ends on victory or defeat

Step 3: Commit

Wire up main module with event handling and game loop

Task 11: Card image mapping

Files:

  • Modify: data/starter-ironclad.json

The card images in assets/images/ironclad/starter/ are numbered. We need to identify which number maps to which card.

Step 1: Read each starter card image

Read the files in assets/images/ironclad/starter/ (0.png, 1.png, 2.png) to identify them by the card name visible on the image. Also check the upgraded/ subfolder.

Step 2: Update image paths in starter JSON

Map each card id to its correct image file path.

Step 3: Commit

Map ironclad starter card images to data

Task 12: Final check

Step 1: Run full test suite

Run: bun test Expected: all pass

Step 2: Run biome check

Run: bun run check Expected: clean

Step 3: Manual playthrough

Run: bun run dev Play one full combat. Verify:

  • cards draw and display correctly
  • selecting and playing cards works
  • energy deducts properly
  • enemy takes damage
  • enemy intent shows
  • end turn triggers enemy attack
  • player takes damage (block absorbs first)
  • bash applies vulnerable, next attack does double
  • game ends with victory or defeat overlay

Step 4: Commit

First playable single combat encounter