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

5.4 KiB
Raw Blame History

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)

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)