darkwatch/docs/superpowers/specs/2026-04-11-initiative-tracker-design.md
Aaron Wood dcf386c52d Add initiative tracker design spec and update roadmap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:16:32 -04:00

201 lines
7.9 KiB
Markdown

# 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.
```sql
ALTER TABLE campaigns ADD COLUMN combat_state JSON NULL DEFAULT NULL;
```
### Combat object shape
```typescript
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
```sql
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
```typescript
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:
```typescript
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