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