docs: add death timer design spec
This commit is contained in:
parent
7c7bdf2ee5
commit
44b482e173
1 changed files with 138 additions and 0 deletions
138
docs/specs/2026-04-12-death-timer-design.md
Normal file
138
docs/specs/2026-04-12-death-timer-design.md
Normal 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 (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`) |
|
||||
Loading…
Add table
Reference in a new issue