diff --git a/docs/specs/2026-04-12-death-timer-design.md b/docs/specs/2026-04-12-death-timer-design.md new file mode 100644 index 0000000..ac17b5a --- /dev/null +++ b/docs/specs/2026-04-12-death-timer-design.md @@ -0,0 +1,138 @@ +# Death Timer — Design Spec + +Last updated: 2026-04-12 + +--- + +## Goal + +Implement Shadowdark's dying mechanic: when a character drops to 0 HP they enter a dying state with a 1d4 + CON modifier round countdown. Each party turn they can roll d20 for recovery (18–20 = stand at 1 HP). If the timer expires they are marked dead. + +--- + +## Data Model + +### Dying state — `character_conditions` (existing table) + +When HP hits 0, insert a condition row: + +``` +name: "Dying" +rounds_remaining: 1d4 + CON modifier (minimum 1, rolled server-side) +description: "" (unused for this condition) +``` + +Using the existing conditions table keeps dying state consistent with the spell mishap system and is automatically cleaned up when a character is healed above 0 HP. + +### Permanent death — `characters` table (new column) + +```sql +ALTER TABLE characters ADD COLUMN is_dead BOOLEAN NOT NULL DEFAULT FALSE; +``` + +`is_dead` is set when the timer reaches 0. It is a persistent character flag — not a condition — because death survives session resets. The DM can manually clear it (via the existing character edit flow) if story reasons bring a character back. + +--- + +## Server Triggers + +### 1. HP hits 0 — auto-start dying + +In `PATCH /characters/:id`, after writing `hp_current`: + +- If new `hp_current <= 0` and character is not already dying and not `is_dead`: + - Roll 1d4 server-side, add CON modifier (from `character_stats`), clamp to minimum 1 + - Insert Dying condition with `rounds_remaining = result` + - Broadcast `character:updated` (includes conditions array) + +- If new `hp_current > 0` and character has a Dying condition: + - Delete the Dying condition (healed out of dying state) + - Broadcast `character:updated` + +### 2. Party turn starts — tick the timer + +In the `initiative:next` socket handler, when `current_side` flips to `"party"`: + +- Query all Dying conditions for characters in this campaign +- Decrement each `rounds_remaining` by 1 +- For any that reach 0: + - Delete the Dying condition + - Set `is_dead = true` on the character + - Broadcast `character:updated` +- Broadcast updated conditions for remaining dying characters via `character:updated` + +### 3. Recovery roll — `death:recovery-roll` + +New socket event emitted by the client when the DM clicks Roll Recovery. + +Payload: `{ campaignId, characterId }` + +Server handler: +- Verify caller is DM of the campaign +- Verify character has a Dying condition +- Roll d20 server-side (via existing `rollDie` utility in `dice.ts`) +- Emit a `roll:result` event to the campaign room (same schema as existing dice rolls) with `label: "Death Save"` so it appears in the existing roll log UI +- If result >= 18: + - Set `hp_current = 1` + - Delete Dying condition + - Broadcast `character:updated` +- Otherwise: no state change (character remains dying) + +--- + +## Client UI + +### Character card — dying indicator + +On `CharacterCard`, when `conditions` includes a "Dying" entry: + +- Add a pulsing red border to the card +- Show `💀 N` (skull + rounds remaining) in the vitals row alongside the HP bar + +When `is_dead === true`: + +- Card renders with a muted/grey visual treatment +- Skull icon persists, HP locked at 0 +- No action buttons (HP +/- disabled) + +### Initiative tracker — recovery roll button + +In `InitiativeTracker`, in the party combatant list, for each character with a Dying condition: + +- Show `"💀 Dying (N rounds)"` label inline +- Show `"⚀ Roll Recovery"` button (DM only) +- Clicking emits `death:recovery-roll` to the server +- Button disappears on success (character stands) or death (timer expires) + +### Roll log + +Recovery rolls appear with a `"Death Save"` label: + +- Failure: `"Morgana — Death Save: 14"` +- Success: `"Morgana — Death Save: 19 — stands at 1 HP!"` + +--- + +## Edge Cases + +- **Already dying when HP drops to 0 again:** No new condition inserted; existing timer continues. +- **Healed above 0 while dying:** Dying condition deleted on the same PATCH that raises HP. No recovery roll needed. +- **CON modifier is negative:** `rounds_remaining = max(1, 1d4 + CON_mod)` — always at least 1 round. +- **Combat ends while character is dying:** Timer stops ticking (no initiative turns). Dying condition persists so it resumes if combat restarts. DM can manually heal or mark dead. +- **`is_dead` character receives healing:** PATCH still updates `hp_current`; `is_dead` is only cleared by a new "Revive" action on the character card (DM only) — a button that sets `is_dead = false` and `hp_current = 1`. This prevents accidental revival from a stray HP edit. The Revive action is in scope for this feature. +- **Multiple dying characters same round:** All timers decrement together on the party turn flip; all death broadcasts fire in the same handler. + +--- + +## Files Affected + +| File | Change | +|---|---| +| `server/migrations/006_death_timer.sql` | Add `is_dead` column to `characters` | +| `server/src/routes/characters.ts` | Auto-start/clear dying on HP change | +| `server/src/socket.ts` | Tick timer on `initiative:next`; handle `death:recovery-roll` | +| `client/src/types.ts` | Add `is_dead` to `Character` type | +| `client/src/components/CharacterCard.tsx` | Dying border + countdown; dead state | +| `client/src/components/InitiativeTracker.tsx` | Dying label + Roll Recovery button | +| `client/src/components/InitiativeTracker.tsx` | Emit `death:recovery-roll` (already listed above, also handles Revive button) | +| `client/src/components/CharacterCard.tsx` | Revive button (DM only, appears when `is_dead`) |