darkwatch/docs/plans/2026-04-11-character-creation.md
Aaron Wood 7c7bdf2ee5 chore: consolidate docs into flat structure and commit all plans
- Move docs/superpowers/{plans,specs}/ → docs/{plans,specs}/
- Add 4 previously untracked implementation plans to git
- Update CLAUDE.md with docs path overrides for superpowers skills
- Update HANDBOOK.md repo structure and workflow paths
- Add per-enemy dice rolls to ROADMAP planned section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:55:45 -04:00

36 KiB
Raw Blame History

Character Creation Wizard 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: Replace the bare "New Character" inline form in CampaignView with a themed 4-step multi-step wizard that walks players through the full Shadowdark character creation process: name/class/ancestry → stat rolling → background/alignment/deity → review & create.

Architecture: A standalone CharacterWizard modal component manages step state internally. A character-creation.ts util handles all dice math (3d6, HP by class, starting gold). A backgrounds.ts data file holds all 20 Shadowdark backgrounds. The server POST /campaigns/:id/characters endpoint is extended to accept full character data so we can create a complete character in one round-trip.

Tech Stack: React + TypeScript + CSS Modules (matching existing patterns); existing getModifier() from client/src/utils/modifiers.ts; existing getShadowdarkTitle() from client/src/utils/shadowdark-titles.ts; existing SelectDropdown component; Express/MariaDB on server.


Task 1: Extend server character creation endpoint

Files:

  • Modify: server/src/routes/characters.ts:93-151

The current endpoint accepts only name, class, ancestry, hp_max. We need it to also accept alignment, background, deity, title, and stat values so we can create a fully-formed character without a second PATCH call.

  • Step 1: Write the failing test (manual — run the server and test via curl after implementation)
# After implementation, verify this works:
curl -s -X POST http://localhost:3001/api/campaigns/1/characters \
  -H "Content-Type: application/json" \
  -b "..." \
  -d '{
    "name": "Tharyn",
    "class": "Fighter",
    "ancestry": "Human",
    "alignment": "Lawful",
    "background": "Soldier",
    "deity": "",
    "title": "Squire",
    "hp_max": 9,
    "gp": 30,
    "stats": {"STR":15,"DEX":10,"CON":12,"INT":8,"WIS":11,"CHA":9}
  }' | jq '.alignment, .background, .title, .gp'
# Expected: "Lawful" "Soldier" "Squire" 30
  • Step 2: Extend the POST handler to accept full character fields

In server/src/routes/characters.ts, replace the destructure at line 97 and the INSERT at line 106:

// Replace lines 97-120 with:
const {
  name,
  class: charClass,
  ancestry,
  hp_max,
  alignment,
  background,
  deity,
  title,
  gp,
  stats,
} = req.body;

if (!name?.trim()) {
  res.status(400).json({ error: "Character name is required" });
  return;
}

const userId = req.user?.userId ?? null;

const [result] = await db.execute<ResultSetHeader>(
  `INSERT INTO characters
     (campaign_id, user_id, name, class, ancestry, hp_current, hp_max,
      alignment, background, deity, title, gp, color)
   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  [
    campaignId,
    userId,
    name.trim(),
    charClass ?? "Fighter",
    ancestry ?? "Human",
    hp_max ?? 1,
    hp_max ?? 1,
    alignment ?? "Neutral",
    background ?? "",
    deity ?? "",
    title ?? "",
    gp ?? 0,
    generateCharacterColor(),
  ]
);
const characterId = result.insertId;
  • Step 3: Use provided stat values instead of default 10s

Replace the Promise.all stat insert block (lines 123-130) with:

const statNames = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
const providedStats: Record<string, number> = stats && typeof stats === "object" ? stats : {};

await Promise.all(
  statNames.map((stat) =>
    db.execute(
      "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, ?)",
      [characterId, stat, providedStats[stat] ?? 10]
    )
  )
);

Then update the enriched object's stats line (currently DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 }))):

const enriched = {
  ...charRows[0],
  overrides: {},
  stats: statNames.map((s) => ({ stat_name: s, value: providedStats[s] ?? 10 })),
  gear: [],
  talents: [],
};
  • Step 4: Run the server and test
cd /Users/aaron.wood/workspace/shadowdark/server && npm run dev

Confirm no TypeScript errors and the curl test from Step 1 returns expected values.

  • Step 5: Commit
git -C /Users/aaron.wood/workspace/shadowdark add server/src/routes/characters.ts
git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: extend character creation endpoint to accept full character data"

Task 2: Backgrounds data file

Files:

  • Create: client/src/data/backgrounds.ts

20 official Shadowdark backgrounds from the Player Quickstart, each with a name and the skill it grants.

  • Step 1: Create the file
// client/src/data/backgrounds.ts
export interface Background {
  name: string;
  skill: string;
}

export const BACKGROUNDS: Background[] = [
  { name: "Urchin",              skill: "You are never lost in a city and can always find food and shelter in urban areas." },
  { name: "Wanted",              skill: "You know how to move unseen and blend into crowds to avoid pursuit." },
  { name: "Cult Initiate",       skill: "You know the rituals, symbols, and secrets of a dark cult." },
  { name: "Thieves' Guild",      skill: "You can pick locks, pocket items, and recognize guild signs." },
  { name: "Banished",            skill: "You can survive in the wilderness indefinitely and sense approaching weather." },
  { name: "Orphaned",            skill: "You can beg, scrounge, and identify charity-givers in any settlement." },
  { name: "Wizard's Apprentice", skill: "You can read and identify magical writing and recognize common spell effects." },
  { name: "Jeweler",             skill: "You can appraise gems, metals, and jewelry with accuracy." },
  { name: "Herbalist",           skill: "You know which plants heal, harm, or alter the mind in any terrain." },
  { name: "Barbarian",           skill: "You can track creatures and navigate by the stars in any wilderness." },
  { name: "Mercenary",           skill: "You know the going rate for violence and can always find hired work." },
  { name: "Sailor",              skill: "You can navigate by stars and read weather patterns at sea or in the open." },
  { name: "Acolyte",             skill: "You know the prayers, calendar, and hierarchy of a major religion." },
  { name: "Soldier",             skill: "You can read a battlefield and know the tactics of common military units." },
  { name: "Ranger",              skill: "You can move silently through wilderness and identify animal tracks." },
  { name: "Scout",               skill: "You can estimate distances, draw rough maps, and spot ambushes." },
  { name: "Minstrel",            skill: "You can perform music, recite epics, and always find a crowd willing to listen." },
  { name: "Scholar",             skill: "You can read most languages and recall obscure historical and arcane lore." },
  { name: "Noble",               skill: "You know the etiquette, rumors, and politics of the upper class." },
  { name: "Chirurgeon",          skill: "You can stabilize the dying and treat wounds to prevent infection." },
];

export const BACKGROUND_NAMES = BACKGROUNDS.map((b) => b.name);
  • Step 2: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git -C /Users/aaron.wood/workspace/shadowdark add client/src/data/backgrounds.ts
git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add Shadowdark backgrounds data file"

Task 3: Character creation utilities

Files:

  • Create: client/src/utils/character-creation.ts

Pure math functions used by the wizard: roll 3d6, calculate HP by class, roll starting gold, calculate derived title.

  • Step 1: Create the util file
// client/src/utils/character-creation.ts
import { getModifier } from "./modifiers.js";
import { getShadowdarkTitle } from "./shadowdark-titles.js";

const CLASS_HIT_DIE: Record<string, number> = {
  Fighter: 8,
  Priest: 6,
  Thief: 4,
  Wizard: 4,
};

/** Roll 3d6 and return the sum (318). */
export function roll3d6(): number {
  return (
    Math.ceil(Math.random() * 6) +
    Math.ceil(Math.random() * 6) +
    Math.ceil(Math.random() * 6)
  );
}

/** Roll a full set of 6 stats (STR/DEX/CON/INT/WIS/CHA). */
export function rollAllStats(): Record<string, number> {
  return {
    STR: roll3d6(),
    DEX: roll3d6(),
    CON: roll3d6(),
    INT: roll3d6(),
    WIS: roll3d6(),
    CHA: roll3d6(),
  };
}

/**
 * Roll HP for level 1: roll the class hit die, add CON modifier.
 * Minimum result is 1.
 */
export function rollStartingHp(charClass: string, conScore: number): number {
  const die = CLASS_HIT_DIE[charClass] ?? 4;
  const rolled = Math.ceil(Math.random() * die);
  return Math.max(1, rolled + getModifier(conScore));
}

/**
 * Roll starting gold: 2d6 × 5 gp.
 */
export function rollStartingGold(): number {
  const d1 = Math.ceil(Math.random() * 6);
  const d2 = Math.ceil(Math.random() * 6);
  return (d1 + d2) * 5;
}

/**
 * Derive starting gear slots: 10 + STR modifier.
 */
export function startingGearSlots(strScore: number): number {
  return 10 + getModifier(strScore);
}

/** Convenience: get title for a level-1 character. */
export function getStartingTitle(charClass: string, alignment: string): string {
  return getShadowdarkTitle(charClass, alignment, 1) ?? "";
}
  • Step 2: Verify TypeScript
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git -C /Users/aaron.wood/workspace/shadowdark add client/src/utils/character-creation.ts
git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add character creation utility functions (roll3d6, rollHp, rollGold)"

Task 4: Extend client API function

Files:

  • Modify: client/src/api.ts:69-76

Extend createCharacter to accept the full set of fields the server now supports.

  • Step 1: Update the createCharacter function signature

Replace the existing createCharacter export:

// Before:
export const createCharacter = (
  campaignId: number,
  data: { name: string; class?: string; ancestry?: string; hp_max?: number },
) =>
  request<Character>(`/campaigns/${campaignId}/characters`, {
    method: "POST",
    body: JSON.stringify(data),
  });
// After:
export interface CreateCharacterData {
  name: string;
  class?: string;
  ancestry?: string;
  alignment?: string;
  background?: string;
  deity?: string;
  title?: string;
  hp_max?: number;
  gp?: number;
  stats?: Record<string, number>;
}

export const createCharacter = (campaignId: number, data: CreateCharacterData) =>
  request<Character>(`/campaigns/${campaignId}/characters`, {
    method: "POST",
    body: JSON.stringify(data),
  });
  • Step 2: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git -C /Users/aaron.wood/workspace/shadowdark add client/src/api.ts
git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: extend createCharacter API to accept full character fields"

Task 5: CharacterWizard component — step state & step 1 (Name / Class / Ancestry)

Files:

  • Create: client/src/components/CharacterWizard.tsx
  • Create: client/src/components/CharacterWizard.module.css

The wizard manages all step state. Step 1 collects name, class, and ancestry. It validates that name is non-empty before Next is enabled.

  • Step 1: Create the CSS file
/* client/src/components/CharacterWizard.module.css */

/* ── Backdrop ─────────────────────────────────────────── */
.backdrop {
  position: fixed;
  inset: 0;
  background: var(--bg-overlay);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 200;
  padding: 1rem;
}

/* ── Dialog ───────────────────────────────────────────── */
.dialog {
  background-color: var(--bg-modal);
  background-image: var(--texture-surface), var(--texture-speckle);
  background-size: 256px 256px, 128px 128px;
  background-repeat: repeat, repeat;
  border: 2px solid rgba(var(--gold-rgb), 0.3);
  border-radius: 4px;
  padding: 1.5rem;
  width: 100%;
  max-width: 480px;
  box-shadow:
    0 8px 40px rgba(var(--shadow-rgb), 0.7),
    0 2px 8px rgba(var(--shadow-rgb), 0.5),
    inset 0 1px 0 rgba(var(--gold-rgb), 0.1),
    inset 0 0 60px rgba(var(--shadow-rgb), 0.2);
}

/* ── Header ───────────────────────────────────────────── */
.title {
  font-family: "Cinzel", Georgia, serif;
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--gold);
  letter-spacing: 0.05em;
  text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.3);
  margin-bottom: 0.25rem;
}

.stepLabel {
  font-size: 0.75rem;
  color: var(--text-tertiary);
  font-family: "Cinzel", Georgia, serif;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 1.25rem;
}

/* ── Progress dots ────────────────────────────────────── */
.dots {
  display: flex;
  gap: 0.4rem;
  margin-bottom: 1.25rem;
}

.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: rgba(var(--gold-rgb), 0.2);
  border: 1px solid rgba(var(--gold-rgb), 0.3);
  transition: background 0.2s;
}

.dotActive {
  background: var(--gold);
  border-color: var(--gold);
}

.dotDone {
  background: rgba(var(--gold-rgb), 0.5);
  border-color: rgba(var(--gold-rgb), 0.5);
}

/* ── Form fields ──────────────────────────────────────── */
.field {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin-bottom: 0.75rem;
}

.fieldLabel {
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.75rem;
  color: var(--text-secondary);
  text-transform: uppercase;
  font-weight: 600;
  letter-spacing: 0.05em;
}

.input {
  padding: 0.5rem 0.75rem;
  background: var(--bg-inset);
  border: 1px solid rgba(var(--gold-rgb), 0.15);
  border-radius: 4px;
  color: var(--text-primary);
  font-size: 0.9rem;
  font-family: "Alegreya", Georgia, serif;
}

.input:focus {
  outline: none;
  border-color: var(--gold);
}

/* ── Stat rolling (step 2) ────────────────────────────── */
.statGrid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}

.statRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.4rem 0.6rem;
  background: var(--bg-inset);
  border: 1px solid rgba(var(--gold-rgb), 0.12);
  border-radius: 4px;
}

.statName {
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.7rem;
  color: var(--text-secondary);
  letter-spacing: 0.05em;
  font-weight: 600;
}

.statValue {
  font-size: 1.1rem;
  font-weight: 700;
  color: var(--text-primary);
  min-width: 2ch;
  text-align: right;
}

.statMod {
  font-size: 0.75rem;
  color: var(--text-tertiary);
  min-width: 3ch;
  text-align: right;
}

.rerollBtn {
  width: 100%;
  padding: 0.5rem;
  background: none;
  border: 1px solid rgba(var(--gold-rgb), 0.25);
  border-radius: 4px;
  color: var(--text-secondary);
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.8rem;
  cursor: pointer;
  letter-spacing: 0.05em;
  transition: border-color 0.15s, color 0.15s;
  margin-bottom: 0.75rem;
}

.rerollBtn:hover {
  border-color: rgba(var(--gold-rgb), 0.5);
  color: var(--gold);
}

/* ── Background list (step 3) ─────────────────────────── */
.bgList {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  max-height: 180px;
  overflow-y: auto;
  margin-bottom: 0.75rem;
  scrollbar-width: thin;
  scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent;
}

.bgItem {
  padding: 0.4rem 0.6rem;
  border-radius: 4px;
  border: 1px solid transparent;
  cursor: pointer;
  font-size: 0.88rem;
  font-family: "Alegreya", Georgia, serif;
  transition: background 0.1s, border-color 0.1s;
}

.bgItem:hover {
  background: rgba(var(--gold-rgb), 0.06);
}

.bgItemSelected {
  background: rgba(var(--gold-rgb), 0.12);
  border-color: rgba(var(--gold-rgb), 0.3);
  color: var(--gold);
}

.bgSkill {
  font-size: 0.75rem;
  color: var(--text-tertiary);
  font-style: italic;
  margin-top: 0.2rem;
}

.randomBtn {
  width: 100%;
  padding: 0.35rem;
  background: none;
  border: 1px dashed rgba(var(--gold-rgb), 0.2);
  border-radius: 4px;
  color: var(--text-tertiary);
  font-size: 0.8rem;
  cursor: pointer;
  font-family: "Cinzel", Georgia, serif;
  letter-spacing: 0.05em;
  transition: border-color 0.15s, color 0.15s;
  margin-bottom: 0.75rem;
}

.randomBtn:hover {
  border-color: rgba(var(--gold-rgb), 0.4);
  color: var(--text-secondary);
}

/* ── Review (step 4) ──────────────────────────────────── */
.reviewGrid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.35rem 1rem;
  margin-bottom: 0.75rem;
}

.reviewRow {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  border-bottom: 1px solid rgba(var(--gold-rgb), 0.08);
  padding-bottom: 0.2rem;
}

.reviewLabel {
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.7rem;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.reviewValue {
  font-size: 0.88rem;
  color: var(--text-primary);
  font-family: "Alegreya", Georgia, serif;
}

.reviewSectionTitle {
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.75rem;
  color: var(--gold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin-bottom: 0.4rem;
  grid-column: 1 / -1;
  border-bottom: 1px solid rgba(var(--gold-rgb), 0.15);
  padding-bottom: 0.2rem;
}

.reviewStats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.35rem;
  margin-bottom: 0.75rem;
}

.reviewStat {
  text-align: center;
  padding: 0.35rem;
  background: var(--bg-inset);
  border-radius: 4px;
  border: 1px solid rgba(var(--gold-rgb), 0.1);
}

.reviewStatName {
  font-family: "Cinzel", Georgia, serif;
  font-size: 0.65rem;
  color: var(--text-tertiary);
  letter-spacing: 0.05em;
}

.reviewStatVal {
  font-size: 1rem;
  font-weight: 700;
  color: var(--text-primary);
}

.reviewStatMod {
  font-size: 0.7rem;
  color: var(--text-tertiary);
}

/* ── Navigation buttons ───────────────────────────────── */
.actions {
  display: flex;
  gap: 0.5rem;
  justify-content: space-between;
  margin-top: 1rem;
}

.btnSecondary {
  padding: 0.5rem 1rem;
  border-radius: 4px;
  font-weight: 600;
  cursor: pointer;
  font-size: 0.9rem;
  background: none;
  border: 1px solid rgba(var(--gold-rgb), 0.2);
  color: var(--text-secondary);
  font-family: "Cinzel", Georgia, serif;
  transition: border-color 0.15s, color 0.15s;
}

.btnSecondary:hover {
  border-color: rgba(var(--gold-rgb), 0.4);
  color: var(--text-primary);
}

.btnPrimary {
  padding: 0.5rem 1.25rem;
  background: var(--btn-gold-bg);
  color: var(--btn-active-text);
  border: none;
  border-radius: 4px;
  font-family: "Cinzel", Georgia, serif;
  font-weight: 600;
  cursor: pointer;
  font-size: 0.9rem;
  box-shadow:
    0 2px 4px rgba(var(--shadow-rgb), 0.3),
    inset 0 1px 0 rgba(255, 255, 255, 0.1);
  text-shadow: 0 1px 1px rgba(var(--shadow-rgb), 0.2);
}

.btnPrimary:hover {
  background: linear-gradient(180deg, var(--gold-bright), var(--gold-hover) 40%, var(--gold));
}

.btnPrimary:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
  • Step 2: Create CharacterWizard.tsx with step 1
// client/src/components/CharacterWizard.tsx
import { useState } from "react";
import type { CreateCharacterData } from "../api.js";
import SelectDropdown from "./SelectDropdown.js";
import { BACKGROUNDS, type Background } from "../data/backgrounds.js";
import {
  rollAllStats,
  rollStartingHp,
  rollStartingGold,
  startingGearSlots,
  getStartingTitle,
} from "../utils/character-creation.js";
import { getModifier, formatModifier } from "../utils/modifiers.js";
import styles from "./CharacterWizard.module.css";

const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
const STAT_NAMES = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];

const STEP_LABELS = [
  "Step 1 of 4 — Name & Origins",
  "Step 2 of 4 — Ability Scores",
  "Step 3 of 4 — Background & Beliefs",
  "Step 4 of 4 — Review & Create",
];

interface Props {
  campaignId: number;
  onSubmit: (data: CreateCharacterData) => Promise<void>;
  onClose: () => void;
}

export default function CharacterWizard({ onSubmit, onClose }: Props) {
  const [step, setStep] = useState(0);

  // Step 1 state
  const [name, setName] = useState("");
  const [charClass, setCharClass] = useState("Fighter");
  const [ancestry, setAncestry] = useState("Human");

  // Step 2 state
  const [stats, setStats] = useState<Record<string, number>>(rollAllStats);

  // Step 3 state
  const [selectedBg, setSelectedBg] = useState<Background | null>(null);
  const [alignment, setAlignment] = useState("Neutral");
  const [deity, setDeity] = useState("");

  // Derived for review / submit
  const conMod = getModifier(stats.CON);
  const hp = rollStartingHp(charClass, stats.CON);
  const gp = rollStartingGold();
  const gearSlots = startingGearSlots(stats.STR);
  const title = getStartingTitle(charClass, alignment);

  // Keep hp/gp stable across re-renders by computing once at step transition
  const [rolledHp, setRolledHp] = useState(0);
  const [rolledGp, setRolledGp] = useState(0);
  const [rolledGearSlots, setRolledGearSlots] = useState(10);

  function handleRerollStats() {
    setStats(rollAllStats());
  }

  function advanceToStep(nextStep: number) {
    if (nextStep === 3 && step === 2) {
      // Roll HP and gold when entering review so they're stable
      setRolledHp(rollStartingHp(charClass, stats.CON));
      setRolledGp(rollStartingGold());
      setRolledGearSlots(startingGearSlots(stats.STR));
    }
    setStep(nextStep);
  }

  async function handleCreate() {
    await onSubmit({
      name: name.trim(),
      class: charClass,
      ancestry,
      alignment,
      background: selectedBg?.name ?? "",
      deity: charClass === "Priest" ? deity : "",
      title,
      hp_max: rolledHp,
      gp: rolledGp,
      stats,
    });
  }

  return (
    <div className={styles.backdrop} onClick={onClose}>
      <div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
        <div className={styles.title}>New Character</div>
        <div className={styles.stepLabel}>{STEP_LABELS[step]}</div>

        {/* Progress dots */}
        <div className={styles.dots}>
          {[0, 1, 2, 3].map((i) => (
            <div
              key={i}
              className={[
                styles.dot,
                i === step ? styles.dotActive : "",
                i < step ? styles.dotDone : "",
              ]
                .filter(Boolean)
                .join(" ")}
            />
          ))}
        </div>

        {/* ── Step 1: Name / Class / Ancestry ── */}
        {step === 0 && (
          <>
            <div className={styles.field}>
              <label className={styles.fieldLabel}>Name</label>
              <input
                className={styles.input}
                type="text"
                value={name}
                onChange={(e) => setName(e.target.value)}
                autoFocus
                placeholder="Tharyn the Bold..."
              />
            </div>
            <div className={styles.field}>
              <label className={styles.fieldLabel}>Class</label>
              <SelectDropdown
                value={charClass}
                options={CLASSES}
                onChange={setCharClass}
              />
            </div>
            <div className={styles.field}>
              <label className={styles.fieldLabel}>Ancestry</label>
              <SelectDropdown
                value={ancestry}
                options={ANCESTRIES}
                onChange={setAncestry}
              />
            </div>
          </>
        )}

        {/* ── Step 2: Stat Rolling ── */}
        {step === 1 && (
          <>
            <div className={styles.statGrid}>
              {STAT_NAMES.map((s) => (
                <div key={s} className={styles.statRow}>
                  <span className={styles.statName}>{s}</span>
                  <span className={styles.statValue}>{stats[s]}</span>
                  <span className={styles.statMod}>
                    {formatModifier(getModifier(stats[s]))}
                  </span>
                </div>
              ))}
            </div>
            <button className={styles.rerollBtn} onClick={handleRerollStats}>
              Reroll All
            </button>
          </>
        )}

        {/* ── Step 3: Background / Alignment / Deity ── */}
        {step === 2 && (
          <>
            <div className={styles.field}>
              <label className={styles.fieldLabel}>Background</label>
            </div>
            <div className={styles.bgList}>
              {BACKGROUNDS.map((bg) => (
                <div
                  key={bg.name}
                  className={[
                    styles.bgItem,
                    selectedBg?.name === bg.name ? styles.bgItemSelected : "",
                  ]
                    .filter(Boolean)
                    .join(" ")}
                  onClick={() => setSelectedBg(bg)}
                >
                  <div>{bg.name}</div>
                  {selectedBg?.name === bg.name && (
                    <div className={styles.bgSkill}>{bg.skill}</div>
                  )}
                </div>
              ))}
            </div>
            <button
              className={styles.randomBtn}
              onClick={() =>
                setSelectedBg(
                  BACKGROUNDS[Math.floor(Math.random() * BACKGROUNDS.length)]
                )
              }
            >
              Random Background
            </button>
            <div className={styles.field}>
              <label className={styles.fieldLabel}>Alignment</label>
              <SelectDropdown
                value={alignment}
                options={ALIGNMENTS}
                onChange={setAlignment}
              />
            </div>
            {charClass === "Priest" && (
              <div className={styles.field}>
                <label className={styles.fieldLabel}>Deity</label>
                <input
                  className={styles.input}
                  type="text"
                  value={deity}
                  onChange={(e) => setDeity(e.target.value)}
                  placeholder="Saint Terragnis..."
                />
              </div>
            )}
          </>
        )}

        {/* ── Step 4: Review ── */}
        {step === 3 && (
          <>
            <div className={styles.reviewGrid}>
              <div className={styles.reviewSectionTitle}>Character</div>
              {[
                ["Name", name],
                ["Class", charClass],
                ["Ancestry", ancestry],
                ["Alignment", alignment],
                ["Title", title],
                ["Background", selectedBg?.name ?? "—"],
                ["HP", String(rolledHp)],
                ["Starting Gold", `${rolledGp} gp`],
                ["Gear Slots", String(rolledGearSlots)],
                ...(charClass === "Priest" && deity ? [["Deity", deity]] : []),
              ].map(([label, value]) => (
                <div key={label} className={styles.reviewRow}>
                  <span className={styles.reviewLabel}>{label}</span>
                  <span className={styles.reviewValue}>{value}</span>
                </div>
              ))}
            </div>

            <div
              className={styles.reviewSectionTitle}
              style={{ fontFamily: "Cinzel, Georgia, serif", fontSize: "0.75rem", color: "var(--gold)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: "0.4rem", borderBottom: "1px solid rgba(var(--gold-rgb), 0.15)", paddingBottom: "0.2rem" }}
            >
              Ability Scores
            </div>
            <div className={styles.reviewStats}>
              {STAT_NAMES.map((s) => (
                <div key={s} className={styles.reviewStat}>
                  <div className={styles.reviewStatName}>{s}</div>
                  <div className={styles.reviewStatVal}>{stats[s]}</div>
                  <div className={styles.reviewStatMod}>
                    {formatModifier(getModifier(stats[s]))}
                  </div>
                </div>
              ))}
            </div>
          </>
        )}

        {/* ── Navigation ── */}
        <div className={styles.actions}>
          <button
            className={styles.btnSecondary}
            onClick={step === 0 ? onClose : () => setStep(step - 1)}
          >
            {step === 0 ? "Cancel" : "Back"}
          </button>
          {step < 3 ? (
            <button
              className={styles.btnPrimary}
              disabled={step === 0 && !name.trim()}
              onClick={() => advanceToStep(step + 1)}
            >
              Next
            </button>
          ) : (
            <button className={styles.btnPrimary} onClick={handleCreate}>
              Create Character
            </button>
          )}
        </div>
      </div>
    </div>
  );
}
  • Step 3: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
git -C /Users/aaron.wood/workspace/shadowdark add client/src/components/CharacterWizard.tsx client/src/components/CharacterWizard.module.css
git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add CharacterWizard multi-step character creation modal"

Task 6: Wire CharacterWizard into CampaignView

Files:

  • Modify: client/src/pages/CampaignView.tsx

Replace the inline showCreate / newChar form with CharacterWizard. Remove the newChar state, the inline form JSX block, and the CLASSES/ANCESTRIES constants from this file (they now live in the wizard).

  • Step 1: Add the import

At the top of CampaignView.tsx, after the existing component imports, add:

import CharacterWizard from "../components/CharacterWizard.js";
  • Step 2: Remove newChar state

Remove this block (around lines 45-50):

const [newChar, setNewChar] = useState({
  name: "",
  class: "Fighter",
  ancestry: "Human",
  hp_max: 1,
});
  • Step 3: Update handleCreate

Replace the existing handleCreate function (around lines 290-299):

// Before:
async function handleCreate(e: React.FormEvent) {
  e.preventDefault();
  if (!newChar.name.trim()) return;
  try {
    await createCharacter(campaignId, newChar);
    setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 });
    setShowCreate(false);
  } catch (err) {
    console.error("Failed to create character:", err);
  }
}
// After:
async function handleCreate(data: import("../api.js").CreateCharacterData) {
  try {
    await createCharacter(campaignId, data);
    setShowCreate(false);
  } catch (err) {
    console.error("Failed to create character:", err);
  }
}
  • Step 4: Replace inline form JSX with CharacterWizard

Find the inline form block in the JSX (around lines 440-508) — the entire {showCreate && (<div className={styles.createModal}>...</div>)} block. Replace it with:

{showCreate && (
  <CharacterWizard
    campaignId={campaignId}
    onSubmit={handleCreate}
    onClose={() => setShowCreate(false)}
  />
)}
  • Step 5: Remove unused imports and constants

Remove CLASSES and ANCESTRIES from the top of CampaignView.tsx (they're now only used internally in CharacterWizard). Also remove the SelectDropdown import if it's no longer used elsewhere in this file (check — it may still be used in the create form, which is now gone).

// Remove these lines if no longer used:
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
import SelectDropdown from "../components/SelectDropdown.js";
  • Step 6: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit

Expected: no errors.

  • Step 7: Commit
git -C /Users/aaron.wood/workspace/shadowdark add client/src/pages/CampaignView.tsx
git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: replace inline create form with CharacterWizard multi-step modal"

Task 7: Visual QA and CSS polish

Files:

  • Modify: client/src/components/CharacterWizard.tsx (minor fixes as needed)
  • Modify: client/src/components/CharacterWizard.module.css (polish as needed)

Open the app, click "+ Add Character", and walk through all 4 steps. Verify each step works as expected and looks good.

  • Step 1: Start the dev server
cd /Users/aaron.wood/workspace/shadowdark/client && npm run dev
  • Step 2: Walk through each step manually

Check:

  • Step 1: Name field auto-focuses. "Next" disabled with empty name. Class/ancestry dropdowns work.

  • Step 2: Stats show correct values. Reroll All gives new numbers. Modifiers display correctly.

  • Step 3: Background list scrolls smoothly. Clicking a background shows skill text. Random Background works. Alignment dropdown works. Deity field only shows for Priest.

  • Step 4: All review values are correct. Title matches class/alignment. HP and gold are reasonable values. Stats block shows all 6 stats.

  • "Create Character" closes modal and shows new character card in grid.

  • Step 3: Fix any visual issues found

Common things to check:

  • Background list scroll height feels right (adjust max-height in .bgList)

  • Review section not cut off on smaller screens (adjust max-width on .dialog if needed)

  • Dark/light mode both look correct

  • Step 4: Commit any CSS fixes

git -C /Users/aaron.wood/workspace/shadowdark add client/src/components/CharacterWizard.tsx client/src/components/CharacterWizard.module.css
git -C /Users/aaron.wood/workspace/shadowdark commit -m "fix: CharacterWizard visual polish"

Implementation Notes

HP is rolled at step transition: rolledHp is set when advancing from step 3 → 4, so the value shown in review is what gets sent to the server. It won't re-roll if the user navigates back and forward.

Gold is also rolled at step transition: Same pattern as HP — rolled once entering review, stable through multiple visits.

Gear slots are derived from STR: startingGearSlots() returns 10 + STR modifier. This is shown in review and sent as part of the create payload. The server currently uses gear_slots_max — ensure the PATCH call in the server endpoint saves this value. If the server's create endpoint doesn't yet support gear_slots_max, add it alongside the other extended fields in Task 1.

Deity field only shows for Priests: Step 3 conditionally renders the deity input. For all other classes it's skipped (empty string sent to server).

Background is optional: A player can advance from step 3 without selecting a background — selectedBg will be null and an empty string is sent to the server.

The conMod / hp / gp / gearSlots computed values at the top of the component are not used directly — they're only used as a seed for the rolled values stored in state. This avoids re-rolling on every render.