# Dice Rolling — Implementation Plan > **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:** Add a complete dice rolling system with server-side roll engine, shared real-time roll log panel, character sheet roll buttons, and advantage/disadvantage support. **Architecture:** Server-side dice parser and roller handles all roll logic. Rolls flow through Socket.IO: client emits `roll:request`, server generates results, saves to `roll_log` table, broadcasts `roll:result` to the campaign room. A collapsible side panel on the campaign view shows the shared roll history. Roll buttons appear on stat rows and attack lines in view mode. **Tech Stack:** Same as existing — React 18, TypeScript, CSS Modules, Node/Express, Socket.IO, better-sqlite3 --- ## File Structure ``` server/src/ ├── dice.ts # CREATE: dice expression parser + roller ├── routes/rolls.ts # CREATE: GET /api/campaigns/:id/rolls ├── db.ts # MODIFY: add roll_log table ├── socket.ts # MODIFY: handle roll:request events ├── index.ts # MODIFY: register rolls route client/src/ ├── types.ts # MODIFY: add RollResult type ├── api.ts # MODIFY: add getRolls() ├── components/ │ ├── RollLog.tsx # CREATE: collapsible side panel │ ├── RollLog.module.css # CREATE: panel styling │ ├── RollEntry.tsx # CREATE: individual roll result card │ ├── RollEntry.module.css # CREATE: card styling + animation │ ├── DiceButton.tsx # CREATE: small reusable dice button │ ├── DiceButton.module.css # CREATE: button styling │ ├── StatsPanel.tsx # MODIFY: add DiceButton to stat rows │ └── AttackBlock.tsx # MODIFY: add DiceButton to attack lines ├── pages/ │ ├── CampaignView.tsx # MODIFY: add RollLog panel, socket events │ └── CampaignView.module.css # MODIFY: layout with side panel ``` --- ### Task 1: Dice Parser + Roll Engine (Server) **Files:** - Create: `server/src/dice.ts` - [ ] **Step 1: Create server/src/dice.ts** ```ts export interface ParsedDice { count: number; sides: number; modifier: number; raw: string; } export interface RollResult { rolls: number[]; modifier: number; total: number; expression: string; error?: string; } export function parseDice(expression: string): ParsedDice | null { const cleaned = expression.trim().toLowerCase().replace(/\s/g, ""); const match = cleaned.match(/^(\d*)d(\d+)([+-]\d+)?$/); if (!match) return null; const count = match[1] ? parseInt(match[1], 10) : 1; const sides = parseInt(match[2], 10); const modifier = match[3] ? parseInt(match[3], 10) : 0; if (count < 1 || count > 100 || sides < 1 || sides > 100) return null; return { count, sides, modifier, raw: expression.trim() }; } export function rollDice( expression: string, options?: { advantage?: boolean; disadvantage?: boolean }, ): RollResult { const parsed = parseDice(expression); if (!parsed) { return { rolls: [], modifier: 0, total: 0, expression, error: `Couldn't parse: ${expression}`, }; } const { count, sides, modifier } = parsed; // Advantage/disadvantage: roll the die twice, pick higher/lower if ( (options?.advantage || options?.disadvantage) && count === 1 && sides === 20 ) { const roll1 = Math.floor(Math.random() * sides) + 1; const roll2 = Math.floor(Math.random() * sides) + 1; const chosen = options.advantage ? Math.max(roll1, roll2) : Math.min(roll1, roll2); return { rolls: [roll1, roll2], modifier, total: chosen + modifier, expression, }; } // Normal roll const rolls: number[] = []; for (let i = 0; i < count; i++) { rolls.push(Math.floor(Math.random() * sides) + 1); } const sum = rolls.reduce((a, b) => a + b, 0); return { rolls, modifier, total: sum + modifier, expression, }; } ``` - [ ] **Step 2: Verify dice module loads** ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e " import { parseDice, rollDice } from './src/dice.js'; console.log('parse 2d6+3:', parseDice('2d6+3')); console.log('parse d20:', parseDice('d20')); console.log('parse invalid:', parseDice('abc')); console.log('roll 1d20:', rollDice('1d20')); console.log('roll 2d6+1:', rollDice('2d6+1')); console.log('roll adv:', rollDice('1d20', { advantage: true })); console.log('roll disadv:', rollDice('1d20', { disadvantage: true })); " ``` Expected: Parsed objects for valid expressions, null for invalid, roll results with arrays. --- ### Task 2: Roll Log Table + Rolls API Endpoint **Files:** - Modify: `server/src/db.ts` - Create: `server/src/routes/rolls.ts` - Modify: `server/src/index.ts` - [ ] **Step 1: Add roll_log table to server/src/db.ts** Read the file first. Add inside the `db.exec()` block, after the game_items table: ```sql CREATE TABLE IF NOT EXISTS roll_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, character_id INTEGER, character_name TEXT NOT NULL DEFAULT 'Roll', type TEXT NOT NULL DEFAULT 'custom', label TEXT NOT NULL, dice_expression TEXT NOT NULL, rolls TEXT NOT NULL DEFAULT '[]', modifier INTEGER NOT NULL DEFAULT 0, total INTEGER NOT NULL DEFAULT 0, advantage INTEGER NOT NULL DEFAULT 0, disadvantage INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); ``` - [ ] **Step 2: Create server/src/routes/rolls.ts** ```ts import { Router } from "express"; import db from "../db.js"; const router = Router({ mergeParams: true }); // GET /api/campaigns/:campaignId/rolls — last 50 rolls router.get("/", (req, res) => { const { campaignId } = req.params; const rolls = db .prepare( "SELECT * FROM roll_log WHERE campaign_id = ? ORDER BY created_at DESC LIMIT 50", ) .all(campaignId) as Array>; const parsed = rolls.map((r) => ({ ...r, rolls: JSON.parse(r.rolls as string), advantage: r.advantage === 1, disadvantage: r.disadvantage === 1, })); res.json(parsed); }); export default router; ``` - [ ] **Step 3: Register rolls route in server/src/index.ts** Read the file first. Add after existing route registrations: ```ts import rollRoutes from "./routes/rolls.js"; app.use("/api/campaigns/:campaignId/rolls", rollRoutes); ``` - [ ] **Step 4: Verify** ```bash rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx -e " import db from './src/db.js'; const cols = db.prepare('PRAGMA table_info(roll_log)').all(); console.log('roll_log cols:', cols.map((c: any) => c.name).join(', ')); " ``` Expected: All roll_log columns listed. --- ### Task 3: Socket.IO Roll Handler **Files:** - Modify: `server/src/socket.ts` - [ ] **Step 1: Update server/src/socket.ts to handle roll requests** Read the file first. Replace the entire file: ```ts import { Server } from "socket.io"; import db from "./db.js"; import { rollDice } from "./dice.js"; export function setupSocket(io: Server) { io.on("connection", (socket) => { socket.on("join-campaign", (campaignId: string) => { socket.join(`campaign:${campaignId}`); }); socket.on("leave-campaign", (campaignId: string) => { socket.leave(`campaign:${campaignId}`); }); socket.on( "roll:request", (data: { campaignId: number; characterId?: number; characterName?: string; type: string; dice: string; label: string; modifier?: number; advantage?: boolean; disadvantage?: boolean; }) => { const result = rollDice(data.dice, { advantage: data.advantage, disadvantage: data.disadvantage, }); if (result.error) { socket.emit("roll:error", { error: result.error }); return; } const row = db .prepare( ` INSERT INTO roll_log (campaign_id, character_id, character_name, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( data.campaignId, data.characterId ?? null, data.characterName || "Roll", data.type || "custom", data.label, data.dice, JSON.stringify(result.rolls), result.modifier, result.total, data.advantage ? 1 : 0, data.disadvantage ? 1 : 0, ); const saved = db .prepare("SELECT * FROM roll_log WHERE id = ?") .get(row.lastInsertRowid) as Record; const broadcast = { ...saved, rolls: result.rolls, advantage: data.advantage || false, disadvantage: data.disadvantage || false, }; io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); }, ); socket.on("disconnect", () => { // Rooms are cleaned up automatically by Socket.IO }); }); } export function broadcastToCampaign( io: Server, campaignId: number, event: string, data: unknown, ) { io.to(`campaign:${campaignId}`).emit(event, data); } ``` - [ ] **Step 2: Verify server starts and roll endpoint works** ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts & sleep 2 curl -s http://localhost:3000/api/campaigns/1/rolls | python3 -m json.tool ``` Expected: Empty array `[]` (no rolls yet). Kill server after. --- ### Task 4: Client Types + API **Files:** - Modify: `client/src/types.ts` - Modify: `client/src/api.ts` - [ ] **Step 1: Add RollResult type to client/src/types.ts** Read the file first. Add after AttackLine: ```ts export interface RollResult { id: number; campaign_id: number; character_id: number | null; character_name: string; type: "attack" | "ability-check" | "custom"; label: string; dice_expression: string; rolls: number[]; modifier: number; total: number; advantage: boolean; disadvantage: boolean; created_at: string; } ``` - [ ] **Step 2: Add getRolls to client/src/api.ts** Read the file first. Add the import and function: ```ts import type { Campaign, Character, Gear, Talent, GameItem, GameTalent, RollResult, } from "./types"; ``` ```ts // Rolls export const getRolls = (campaignId: number) => request(`/campaigns/${campaignId}/rolls`); ``` --- ### Task 5: DiceButton + RollEntry Components **Files:** - Create: `client/src/components/DiceButton.tsx` - Create: `client/src/components/DiceButton.module.css` - Create: `client/src/components/RollEntry.tsx` - Create: `client/src/components/RollEntry.module.css` - [ ] **Step 1: Create DiceButton.module.css** ```css .btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #444; background: #16213e; color: #888; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; transition: border-color 0.15s, color 0.15s, background 0.15s; } .btn:hover { border-color: #c9a84c; color: #c9a84c; background: rgba(201, 168, 76, 0.1); } .btn:active { background: rgba(201, 168, 76, 0.25); } ``` - [ ] **Step 2: Create DiceButton.tsx** ```tsx import socket from "../socket"; import styles from "./DiceButton.module.css"; interface DiceButtonProps { campaignId: number; characterId?: number; characterName?: string; type: "attack" | "ability-check" | "custom"; dice: string; label: string; damageDice?: string; damageLabel?: string; } export default function DiceButton({ campaignId, characterId, characterName, type, dice, label, damageDice, damageLabel, }: DiceButtonProps) { function handleClick(e: React.MouseEvent) { const advantage = e.shiftKey; const disadvantage = e.ctrlKey || e.metaKey; socket.emit("roll:request", { campaignId, characterId, characterName, type, dice, label, advantage: advantage && !disadvantage, disadvantage: disadvantage && !advantage, }); // If this is an attack with a damage die, roll damage too if (damageDice && damageLabel) { setTimeout(() => { socket.emit("roll:request", { campaignId, characterId, characterName, type: "attack", dice: damageDice, label: damageLabel, }); }, 100); } } return ( ); } ``` - [ ] **Step 3: Create RollEntry.module.css** ```css .card { background: #0f1a30; border: 1px solid #2a2a4a; border-radius: 6px; padding: 0.5rem 0.6rem; animation: slideIn 0.3s ease-out; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .card.fresh { border-color: #c9a84c; animation: slideIn 0.3s ease-out, glow 1s ease-out; } @keyframes glow { 0% { box-shadow: 0 0 8px rgba(201, 168, 76, 0.4); } 100% { box-shadow: none; } } .topLine { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.2rem; } .charName { font-weight: 700; font-size: 0.8rem; color: #c9a84c; } .timestamp { font-size: 0.65rem; color: #555; } .label { font-size: 0.75rem; color: #888; margin-bottom: 0.2rem; } .breakdown { font-size: 0.75rem; color: #666; margin-bottom: 0.15rem; } .dieResult { color: #e0e0e0; font-weight: 600; } .dieChosen { color: #c9a84c; font-weight: 700; } .dieDiscarded { color: #555; text-decoration: line-through; } .modLine { font-size: 0.7rem; color: #666; } .total { font-size: 1.3rem; font-weight: 700; color: #e0e0e0; text-align: center; margin-top: 0.2rem; } .advantage { color: #4caf50; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; } .disadvantage { color: #e74c3c; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; } .error { color: #e74c3c; font-size: 0.8rem; font-style: italic; } ``` - [ ] **Step 4: Create RollEntry.tsx** ```tsx import type { RollResult } from "../types"; import styles from "./RollEntry.module.css"; interface RollEntryProps { roll: RollResult; fresh?: boolean; } function timeAgo(dateStr: string): string { const now = Date.now(); const then = new Date(dateStr + "Z").getTime(); const seconds = Math.floor((now - then) / 1000); if (seconds < 10) return "just now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); return `${hours}h ago`; } function formatBreakdown(roll: RollResult): string { const { rolls, advantage, disadvantage, dice_expression } = roll; if ((advantage || disadvantage) && rolls.length === 2) { const chosen = advantage ? Math.max(...rolls) : Math.min(...rolls); const parts = rolls.map((r) => (r === chosen ? `**${r}**` : `~~${r}~~`)); return `d20: [${parts.join(", ")}] → ${chosen}`; } return `${dice_expression}: [${rolls.join(", ")}]`; } export default function RollEntry({ roll, fresh }: RollEntryProps) { const { rolls, advantage, disadvantage, dice_expression } = roll; const isAdvantage = advantage && rolls.length === 2; const isDisadvantage = disadvantage && rolls.length === 2; const chosen = isAdvantage ? Math.max(...rolls) : isDisadvantage ? Math.min(...rolls) : null; return (
{roll.character_name} {timeAgo(roll.created_at)}
{roll.label} {isAdvantage && ADV} {isDisadvantage && DIS}
{dice_expression}: [ {rolls.map((r, i) => ( {i > 0 && ", "} {r} ))} ]{chosen !== null && ` → ${chosen}`}
{roll.modifier !== 0 && (
{roll.modifier > 0 ? "+" : ""} {roll.modifier}
)}
{roll.total}
); } ``` --- ### Task 6: RollLog Side Panel **Files:** - Create: `client/src/components/RollLog.tsx` - Create: `client/src/components/RollLog.module.css` - [ ] **Step 1: Create RollLog.module.css** ```css .panel { width: 300px; height: 100%; background: #1a1a2e; border-left: 1px solid #333; display: flex; flex-direction: column; transition: width 0.2s; } .panel.collapsed { width: 40px; cursor: pointer; } .header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; border-bottom: 1px solid #333; } .title { font-size: 0.85rem; font-weight: 700; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.05em; } .collapseBtn { background: none; border: none; color: #888; cursor: pointer; font-size: 1rem; padding: 0.2rem; } .collapseBtn:hover { color: #c9a84c; } .collapsedContent { display: flex; flex-direction: column; align-items: center; padding-top: 0.5rem; gap: 0.5rem; } .collapsedIcon { font-size: 1.2rem; cursor: pointer; } .collapsedLast { writing-mode: vertical-rl; font-size: 0.7rem; color: #888; max-height: 100px; overflow: hidden; } .inputArea { padding: 0.5rem 0.75rem; border-bottom: 1px solid #333; } .input { width: 100%; padding: 0.4rem 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.85rem; } .input:focus { outline: none; border-color: #c9a84c; } .hint { font-size: 0.6rem; color: #555; margin-top: 0.25rem; text-align: center; } .entries { flex: 1; overflow-y: auto; padding: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; scrollbar-width: thin; scrollbar-color: #333 transparent; } .entries::-webkit-scrollbar { width: 4px; } .entries::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } .empty { text-align: center; color: #555; font-size: 0.8rem; font-style: italic; padding: 2rem 0; } ``` - [ ] **Step 2: Create RollLog.tsx** ```tsx import { useState } from "react"; import type { RollResult } from "../types"; import socket from "../socket"; import RollEntry from "./RollEntry"; import styles from "./RollLog.module.css"; interface RollLogProps { campaignId: number; rolls: RollResult[]; freshIds: Set; } export default function RollLog({ campaignId, rolls, freshIds }: RollLogProps) { const [collapsed, setCollapsed] = useState(false); const [input, setInput] = useState(""); function handleRoll(e: React.FormEvent) { e.preventDefault(); if (!input.trim()) return; socket.emit("roll:request", { campaignId, type: "custom", dice: input.trim(), label: input.trim(), }); setInput(""); } if (collapsed) { return (
setCollapsed(false)} >
🎲 {rolls.length > 0 && ( {rolls[0].total} )}
); } return (
Roll Log
setInput(e.target.value)} placeholder="Roll dice... (e.g. 2d6+1)" />
Shift: advantage · Ctrl: disadvantage
{rolls.length === 0 &&

No rolls yet

} {rolls.map((roll) => ( ))}
); } ``` --- ### Task 7: Add DiceButton to StatsPanel and AttackBlock **Files:** - Modify: `client/src/components/StatsPanel.tsx` - Modify: `client/src/components/StatBlock.tsx` - Modify: `client/src/components/StatBlock.module.css` - Modify: `client/src/components/AttackBlock.tsx` - [ ] **Step 1: Add campaignId prop to StatsPanel and pass to StatBlock** Read StatsPanel.tsx. Add `campaignId` to the props interface: ```tsx interface StatsPanelProps { character: Character; mode: "view" | "edit"; campaignId: number; onStatChange: (characterId: number, statName: string, value: number) => void; } ``` Pass `campaignId` and character info to StatBlock: ```tsx onStatChange(character.id, statName, value) } mode={mode} campaignId={campaignId} characterId={character.id} characterName={character.name} /> ``` Also pass campaignId and character to AttackBlock: ```tsx ``` - [ ] **Step 2: Update StatBlock to show DiceButton in view mode** Read StatBlock.tsx. Add `campaignId`, `characterId`, `characterName` to props: ```tsx interface StatBlockProps { stats: Stat[]; onStatChange: (statName: string, newValue: number) => void; mode?: "view" | "edit"; campaignId?: number; characterId?: number; characterName?: string; } ``` Import DiceButton and getModifier/formatModifier. In view mode, after the modifier span, render a DiceButton: ```tsx { mode === "view" && campaignId && ( = 0 ? "+" + mod : String(mod)}`} label={`${name} check`} /> ); } ``` Import at top: ```tsx import DiceButton from "./DiceButton"; ``` Remove the `.rollSpace` CSS class usage since DiceButton replaces it. - [ ] **Step 3: Update AttackBlock to show DiceButton in view mode** Read AttackBlock.tsx. Add `campaignId`, `characterId`, `characterName`, `mode` to props: ```tsx interface AttackBlockProps { attacks: AttackLine[]; campaignId?: number; characterId?: number; characterName?: string; mode?: "view" | "edit"; } ``` Import DiceButton. Replace the rollSpace span in each weapon line with a DiceButton: ```tsx { mode === "view" && campaignId ? ( = 0 ? "+" + atk.modifier : String(atk.modifier)}`} label={`${atk.name} attack`} damageDice={atk.damage} damageLabel={`${atk.name} damage`} /> ) : ( ); } ``` --- ### Task 8: Wire RollLog into CampaignView **Files:** - Modify: `client/src/pages/CampaignView.tsx` - Modify: `client/src/pages/CampaignView.module.css` - Modify: `client/src/components/CharacterSheet.tsx` - [ ] **Step 1: Update CampaignView layout for side panel** Read CampaignView.module.css. Wrap the existing content in a flex container with the roll log on the right. Add: ```css .layout { display: flex; height: calc(100vh - 100px); } .main { flex: 1; overflow-y: auto; padding-right: 0.5rem; } ``` - [ ] **Step 2: Update CampaignView.tsx** Read the file. Add these changes: a) Import RollLog and getRolls: ```tsx import RollLog from "../components/RollLog"; import { getRolls } from "../api"; import type { Character, Gear, Talent, GameItem, RollResult } from "../types"; ``` b) Add state for rolls and fresh IDs: ```tsx const [rolls, setRolls] = useState([]); const [freshIds, setFreshIds] = useState>(new Set()); ``` c) In the useEffect that fetches characters and joins the socket room, also fetch rolls: ```tsx getRolls(campaignId).then(setRolls); ``` d) Add socket listener for `roll:result` in the socket useEffect: ```tsx function onRollResult(roll: RollResult) { setRolls((prev) => [roll, ...prev].slice(0, 50)); setFreshIds((prev) => new Set(prev).add(roll.id)); setTimeout(() => { setFreshIds((prev) => { const next = new Set(prev); next.delete(roll.id); return next; }); }, 2000); } socket.on("roll:result", onRollResult); ``` And in the cleanup: ```tsx socket.off("roll:result", onRollResult); ``` e) Wrap the JSX in the layout flex container and add RollLog: ```tsx return (
{/* existing header, grid, modals */}
); ``` f) Pass `campaignId` to CharacterDetail so it can reach StatsPanel/AttackBlock. Add `campaignId` prop to CharacterDetail, CharacterSheet: In CharacterDetail.tsx, add `campaignId: number` to props and pass through to CharacterSheet. In CharacterSheet.tsx, add `campaignId: number` to props and pass to StatsPanel: ```tsx ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: No errors. --- ### Task 9: End-to-End Smoke Test **Files:** None (testing only) - [ ] **Step 1: Reset DB and start full stack** ```bash rm -f /Users/aaron.wood/workspace/shadowdark/server/data/shadowdark.db lsof -ti:3000 | xargs kill 2>/dev/null; lsof -ti:5173 | xargs kill 2>/dev/null cd /Users/aaron.wood/workspace/shadowdark && npm run dev ``` - [ ] **Step 2: Test in browser** Open http://localhost:5173, go into a campaign: 1. **Roll log panel** visible on the right side 2. Type "1d20" in the general roller input, press Enter — roll result appears in the log 3. Type "2d6+3" — result shows two dice and modifier 4. Collapse the panel — should show thin strip with last result 5. Expand — full panel returns - [ ] **Step 3: Test character sheet roll buttons** 1. Open a character sheet (view mode) 2. Click 🎲 next to a stat — roll appears in log with character name and "STR check" etc. 3. Shift+click — roll shows ADV with two d20s 4. Ctrl+click — roll shows DIS with two d20s 5. Click 🎲 on an attack line — two rolls appear: attack d20 then damage die - [ ] **Step 4: Test real-time sync** Open second tab: 1. Roll in tab 1 — appears in tab 2's roll log 2. Roll in tab 2 — appears in tab 1's roll log 3. Both see the pop-in animation - [ ] **Step 5: Test persistence** 1. Make several rolls 2. Refresh the page 3. Navigate back to the campaign 4. Roll log should show previous rolls (loaded from DB)