darkwatch/docs/specs/2026-04-11-initiative-tracker-design.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

7.9 KiB

Initiative Tracker — Design Spec

Last updated: 2026-04-11


Goal

A DM-managed combat tracker that handles Shadowdark's team-based initiative. The party rolls individually and the system takes the highest; the DM rolls once for all enemies. Synced in real time via socket. Combat state persists to the DB so sessions can pause and resume mid-fight.


Shadowdark Initiative Rules

  • Both sides roll a d20 at the start of combat.
  • Party roll: each player rolls their own d20; the highest single result represents the party.
  • Enemy roll: the DM rolls one d20 for all enemies.
  • Whichever side rolls higher acts first (all members of that side act, then the other side). Ties go to the party.
  • This continues round by round until combat ends. Initiative is not re-rolled each round.

Future Adaptability

The data model uses a mode field ("team" now) to support individual-initiative games later (Cyberpunk RED, D&D 5e, etc.), where each combatant has their own roll and a unified sorted order. No UI or logic for individual mode is built now.


Data Model

campaigns.combat_state column

A nullable JSON array column added to the campaigns table. Each element is one combat. There will almost always be exactly one, but an array allows edge cases (split party, parallel encounters) without schema changes.

ALTER TABLE campaigns ADD COLUMN combat_state JSON NULL DEFAULT NULL;

Combat object shape

interface CombatState {
  id: string;                         // uuid, generated on start
  label?: string;                     // optional DM label e.g. "Throne Room"
  mode: "team";                       // future: "individual"
  round: number;                      // starts at 1
  phase: "rolling" | "active";       // rolling = waiting for initiative rolls; active = combat running
  current_side: "party" | "enemies"; // whose turn it is
  party_roll: number | null;          // highest player roll received
  enemy_roll: number | null;          // DM's roll for enemies
  party_rolls: Record<number, number>; // characterId → individual roll (for display in sidebar)
  character_ids: number[];            // campaign character IDs in this combat
  enemies: Enemy[];
}

interface Enemy {
  id: string;          // client-generated uuid
  name: string;
  hp_current: number;
  hp_max: number;
}

Default value for campaigns.combat_state is NULL (no combat). An empty array [] also means no combat.


Socket Events

All events are namespaced under initiative: and scoped to the campaign room.

Event Direction Who emits Description
initiative:state server → clients server Full state on campaign join
initiative:start client → server DM Open rolling phase, set enemies + character_ids
initiative:roll client → server any player Submit a d20 initiative roll
initiative:enemy-roll client → server DM Submit enemy d20 roll
initiative:begin client → server DM End rolling phase, start active combat
initiative:next client → server DM Advance to next side, increment round when needed
initiative:update-enemy client → server DM Edit enemy name or HP
initiative:add-enemy client → server DM Add an enemy to active combat
initiative:remove-enemy client → server DM Remove an enemy (died, fled)
initiative:end client → server DM End combat, remove from array
initiative:updated server → clients server Broadcast updated combat array after any change

HP visibility

Before broadcasting initiative:updated, the server checks the recipient's role. Non-DM sockets receive the combat array with hp_current and hp_max stripped from all enemies. Enemy names remain visible to everyone.


Server Changes

DB migration

ALTER TABLE campaigns ADD COLUMN combat_state JSON NULL DEFAULT NULL;

Load on join

When a client joins a campaign room, the server reads combat_state from the DB and sends it via initiative:state. If the column is NULL, send [].

Persist on every change

Every initiative:* handler from the DM reads the current combat_state array from memory (cached on join), applies the mutation, writes the updated array back to campaigns.combat_state, then broadcasts initiative:updated to the room (with HP stripped for non-DM sockets).

New route / handler file

server/src/routes/initiative.ts — registers all socket handlers for initiative:* events. Called from server/src/index.ts alongside existing route registrations.


Client Changes

State in CampaignView

const [combats, setCombats] = useState<CombatState[]>([]);

Populated from initiative:state on join and updated by initiative:updated.

Layout change

When combats.length > 0, the main content area switches to a two-column layout: InitiativeTracker sidebar (180px) on the left, character card grid on the right. The roll log stays in its existing position on the right edge. When no combat is active, the layout is unchanged.

New component: InitiativeTracker

client/src/components/InitiativeTracker.tsx + InitiativeTracker.module.css

Props:

interface InitiativeTrackerProps {
  combat: CombatState;
  characters: Character[];   // full campaign character list (for names/colors)
  isDM: boolean;
  currentUserId: number;     // matched against character.user_id to find the player's character and submit their roll
  campaignId: number;
  onRoll: (value: number) => void;
}

The component handles two phases internally:

Rolling phase (phase === "rolling"):

  • Shows each party member with their roll result (or a "—" if not yet rolled)
  • Each player sees a "Roll Initiative" d20 button — clicking rolls a d20 via the existing dice system and emits initiative:roll
  • DM sees a "Roll for Enemies" button — clicking rolls a d20 and emits initiative:enemy-roll
  • DM sees a "Begin Combat" button (enabled once the DM has rolled for enemies; party rolls optional)
  • The DM's sidebar shows enemy rolls and party rolls coming in live

Active phase (phase === "active"):

  • Two blocks: Party and Enemies
  • Active side has a gold left-border inset highlight
  • DM sees: "Next Turn" button, enemy HP fields (editable inline), "Add Enemy" button, per-enemy remove (✕)
  • Players see: enemy names only, no HP, no controls
  • Round counter displayed in the sidebar header

"⚔ Combat" header button

DM-only. Opens a modal/sheet to configure the combat before starting: label (optional), which characters are in the fight (defaults to all), enemy entries (name + max HP). Submits → emits initiative:start.

Roll integration

When a player clicks "Roll Initiative" in the tracker:

  1. The existing dice roll mechanism fires a d20 (with animation)
  2. The result is sent via initiative:roll to the server
  3. It also appears in the roll log as a normal roll entry (subtype: "initiative")

The roll log entry is a nice-to-have audit trail. The tracker captures the result independently.


Visibility Rules Summary

Data Party players DM
Combat is active
Round number
Whose side is active
Party member names + rolls
Enemy names
Enemy HP
Controls (Next, Add, Remove, Begin)

Roadmap Items Added

  • Creature gallery / NPC system — DM-managed library of enemy stat blocks; quick-add to initiative tracker with pre-filled name + HP. Designed to feed into the tracker's "Add Enemy" flow.

Out of Scope (this version)

  • Individual initiative mode (each combatant has their own roll/slot)
  • Creature gallery or saved enemy templates
  • Conditions tracked per combatant in the tracker (conditions UI is a separate roadmap item)
  • Turn timer per combatant
  • Re-rolling initiative mid-combat