138 lines
5.4 KiB
Markdown
138 lines
5.4 KiB
Markdown
# 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`) |
|