docs: add death timer design spec

This commit is contained in:
Aaron Wood 2026-04-12 00:49:30 -04:00
parent 7c7bdf2ee5
commit 44b482e173

View file

@ -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 (1820 = 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`) |