darkwatch/docs/specs/2026-04-12-death-timer-design.md
2026-04-12 00:49:30 -04:00

138 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`) |