darkwatch/docs/plans/2026-04-12-death-timer.md
2026-04-12 01:05:36 -04:00

30 KiB
Raw Blame History

Death Timer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement Shadowdark's dying mechanic — HP→0 starts a dying countdown (1d4+CON rounds), party turns tick the timer, DM can roll d20 each turn for recovery (18+ = stand at 1 HP), timer expiry marks permanent death with a DM-only Revive button.

Architecture: Dying state lives in character_conditions (existing table) as a "Dying" row with rounds_remaining. Permanent death is a new is_dead BOOLEAN column on characters (migration 004). Server drives all state: HP PATCH triggers dying start/clear, initiative:next ticks the timer, death:recovery-roll handles recovery rolls. character:updated broadcasts include a conditions array so clients always have current state.

Tech Stack: Express REST (PATCH /characters/:id), Socket.IO (death:recovery-roll), MariaDB, React + CSS Modules


File Map

File Change
server/migrations/004_death_timer.sql New — adds is_dead column
server/src/routes/characters.ts enrichCharacters includes conditions; PATCH adds conditions to response, dying logic, is_dead in allowedFields
server/src/routes/initiative.ts initiative:next ticks Dying timers when flipping to party
server/src/socket.ts New death:recovery-roll handler
client/src/types.ts Add is_dead and conditions to Character
client/src/components/CharacterCard.tsx Dying border + 💀N countdown; dead state; Revive button (DM only)
client/src/components/CharacterCard.module.css Dying pulse animation; dead mute; dying label; revive button styles
client/src/components/InitiativeTracker.tsx Dying label + Roll Recovery button in ActivePhase

Task 1: DB Migration — Add is_dead column

Files:

  • Create: server/migrations/004_death_timer.sql

  • Step 1: Write the migration

Create server/migrations/004_death_timer.sql:

ALTER TABLE characters ADD COLUMN is_dead BOOLEAN NOT NULL DEFAULT FALSE;
  • Step 2: Apply the migration

Restart the server (migrations run automatically on startup):

cd server && npm run dev

Expected: server logs Running migration: 004_death_timer.sql then Migrations complete.

  • Step 3: Verify column exists
docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
  -e "DESCRIBE characters;" | grep is_dead

Expected output contains: is_dead | tinyint(1) | NO | | 0 |

  • Step 4: Commit
git add server/migrations/004_death_timer.sql
git commit -m "feat: add is_dead column to characters (death timer migration 004)"

Task 2: Enrich characters with conditions + update client types

Files:

  • Modify: server/src/routes/characters.ts
  • Modify: client/src/types.ts

The character:updated socket event currently sends a flat character row (no stats/gear/talents). We extend it to also include conditions so the client always has up-to-date dying state. The existing merge pattern { ...c, ...data } in CampaignView.tsx:131 handles partial updates safely.

  • Step 1: Add conditions to enrichCharacters in characters.ts

enrichCharacters (lines 5175 of server/src/routes/characters.ts) — add a conditions fetch and include it in the return value:

async function enrichCharacters(characters: RowDataPacket[]) {
  return Promise.all(
    characters.map(async (char) => {
      const [stats] = await db.execute<RowDataPacket[]>(
        "SELECT stat_name, value FROM character_stats WHERE character_id = ?",
        [char.id]
      );
      const [gear] = await db.execute<RowDataPacket[]>(
        "SELECT * FROM character_gear WHERE character_id = ?",
        [char.id]
      );
      const [talents] = await db.execute<RowDataPacket[]>(
        "SELECT * FROM character_talents WHERE character_id = ?",
        [char.id]
      );
      const [conditions] = await db.execute<RowDataPacket[]>(
        "SELECT * FROM character_conditions WHERE character_id = ?",
        [char.id]
      );
      return {
        ...char,
        overrides: parseJson(char.overrides),
        stats,
        gear: parseGear(gear),
        talents: parseTalents(talents),
        conditions,
      };
    })
  );
}
  • Step 2: Add conditions to the PATCH handler response

The PATCH handler (lines 215227 of server/src/routes/characters.ts) fetches SELECT * FROM characters then builds an enriched object. Replace that block to also fetch conditions:

const [rows] = await db.execute<RowDataPacket[]>(
  "SELECT * FROM characters WHERE id = ?",
  [id]
);
const [conditions] = await db.execute<RowDataPacket[]>(
  "SELECT * FROM character_conditions WHERE character_id = ?",
  [id]
);
const enriched = {
  ...rows[0],
  overrides: parseJson(rows[0].overrides),
  conditions,
};
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(rows[0].campaign_id), "character:updated", enriched);
res.json(enriched);
  • Step 3: Update client types

In client/src/types.ts, add is_dead and conditions to the Character interface (after torch_lit_at):

export interface Character {
  id: number;
  campaign_id: number;
  user_id?: number | null;
  created_by: string;
  name: string;
  class: string;
  ancestry: string;
  level: number;
  xp: number;
  hp_current: number;
  hp_max: number;
  ac: number;
  alignment: string;
  title: string;
  notes: string;
  background: string;
  deity: string;
  languages: string;
  gp: number;
  sp: number;
  cp: number;
  gear_slots_max: number;
  overrides: Record<string, unknown>;
  color: string;
  luck_token: number;
  torch_lit_at: string | null;
  is_dead: boolean;
  stats: Stat[];
  gear: Gear[];
  talents: Talent[];
  conditions: Condition[];
}
  • Step 4: Verify TypeScript compiles
cd client && npx tsc --noEmit 2>&1 | head -30

Expected: no new errors. (Pre-existing errors unrelated to this feature are OK.)

cd server && npx tsc --noEmit 2>&1 | head -30

Expected: no new errors.

  • Step 5: Commit
git add server/src/routes/characters.ts client/src/types.ts
git commit -m "feat: include conditions in character responses; add is_dead + conditions to Character type"

Task 3: Auto-start/clear dying on HP change

Files:

  • Modify: server/src/routes/characters.ts

When the HP PATCH writes hp_current <= 0 and the character isn't already dying or dead, insert a Dying condition (1d4 + CON modifier rounds, minimum 1). When HP goes above 0, delete any Dying condition.

  • Step 1: Add rollDice import

At the top of server/src/routes/characters.ts, add:

import { rollDice } from "../dice.js";
  • Step 2: Add is_dead to allowedFields in PATCH handler

In the allowedFields array (around line 185), add "is_dead":

const allowedFields = [
  "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max",
  "ac", "alignment", "title", "notes", "background", "deity", "languages",
  "gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token",
  "torch_lit_at", "is_dead",
];

This allows the Revive button (Task 6) to send { is_dead: false, hp_current: 1 } in a single PATCH.

  • Step 3: Insert dying state management block

After the if (updateResult.affectedRows === 0) check (around line 213) and before the SELECT * FROM characters fetch, insert:

// Auto-start or clear Dying condition based on HP change
if (req.body.hp_current !== undefined) {
  const newHp = Number(req.body.hp_current);

  if (newHp <= 0) {
    // Check if already dying or permanently dead
    const [dyingRows] = await db.execute<RowDataPacket[]>(
      "SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
      [id]
    );
    const [deadRows] = await db.execute<RowDataPacket[]>(
      "SELECT is_dead FROM characters WHERE id = ?",
      [id]
    );
    const isAlreadyDying = dyingRows.length > 0;
    const isAlreadyDead = Boolean(deadRows[0]?.is_dead);

    if (!isAlreadyDying && !isAlreadyDead) {
      // Roll 1d4, add CON modifier, clamp to minimum 1
      const d4 = rollDice("1d4");
      const [statRows] = await db.execute<RowDataPacket[]>(
        "SELECT value FROM character_stats WHERE character_id = ? AND stat_name = 'CON'",
        [id]
      );
      const conValue = (statRows[0]?.value as number) ?? 10;
      const conMod = Math.floor((conValue - 10) / 2);
      const roundsRemaining = Math.max(1, d4.total + conMod);

      await db.execute(
        "INSERT INTO character_conditions (character_id, name, description, rounds_remaining) VALUES (?, 'Dying', '', ?)",
        [id, roundsRemaining]
      );
    }
  } else {
    // HP above 0: remove any Dying condition (character was healed)
    await db.execute(
      "DELETE FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
      [id]
    );
  }
}
  • Step 4: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20

Expected: no errors.

  • Step 5: Smoke-test manually

With server running: use the DM login, open a campaign, set a character's HP to 0 via the HP bar. Reload the page and verify a Dying condition with rounds_remaining exists in the DB:

docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
  -e "SELECT * FROM character_conditions WHERE name = 'Dying';"

Set HP back to 1 — verify the row is gone.

  • Step 6: Commit
git add server/src/routes/characters.ts
git commit -m "feat: auto-start Dying condition when HP hits 0, clear when HP recovers"

Task 4: Tick death timer on party turn

Files:

  • Modify: server/src/routes/initiative.ts

When initiative:next flips the side to "party", decrement all Dying conditions for characters in the campaign. Conditions at 0 mark the character dead.

  • Step 1: Add tickDeathTimers helper

Add this function above registerInitiativeHandlers in server/src/routes/initiative.ts:

async function tickDeathTimers(io: Server, campaignId: number): Promise<void> {
  // Find all living characters in this campaign with a Dying condition
  const [charRows] = await db.execute<RowDataPacket[]>(
    "SELECT id FROM characters WHERE campaign_id = ? AND is_dead = FALSE",
    [campaignId]
  );
  if (charRows.length === 0) return;

  const charIds = (charRows as RowDataPacket[]).map((r) => r.id as number);
  const placeholders = charIds.map(() => "?").join(", ");

  const [dyingRows] = await db.execute<RowDataPacket[]>(
    `SELECT * FROM character_conditions WHERE name = 'Dying' AND character_id IN (${placeholders})`,
    charIds
  );
  if (dyingRows.length === 0) return;

  for (const condition of dyingRows) {
    const newRounds = (condition.rounds_remaining as number) - 1;

    if (newRounds <= 0) {
      // Timer expired — remove Dying condition and mark permanently dead
      await db.execute("DELETE FROM character_conditions WHERE id = ?", [condition.id]);
      await db.execute("UPDATE characters SET is_dead = TRUE WHERE id = ?", [condition.character_id]);
    } else {
      await db.execute(
        "UPDATE character_conditions SET rounds_remaining = ? WHERE id = ?",
        [newRounds, condition.id]
      );
    }

    // Broadcast updated character to the campaign room
    const [charRow] = await db.execute<RowDataPacket[]>(
      "SELECT * FROM characters WHERE id = ?",
      [condition.character_id]
    );
    const [updatedConditions] = await db.execute<RowDataPacket[]>(
      "SELECT * FROM character_conditions WHERE character_id = ?",
      [condition.character_id]
    );
    io.to(`campaign:${campaignId}`).emit("character:updated", {
      ...charRow[0],
      conditions: updatedConditions,
    });
  }
}
  • Step 2: Call tickDeathTimers in initiative:next handler

In the initiative:next handler (around line 292), after broadcast(io, socket, data.campaignId, updated, true); and before the } catch, add:

// Tick death timers when the party's turn begins
const flippedCombat = updated.find((c) => c.id === data.combatId);
if (flippedCombat && flippedCombat.current_side === "party") {
  await tickDeathTimers(io, data.campaignId);
}
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20

Expected: no errors.

  • Step 4: Smoke-test manually

Set a character's HP to 0 (gains Dying condition). Start combat with that character. Advance turns until the enemy side ends and the party side begins. Check that rounds_remaining decremented in the DB:

docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \
  -e "SELECT * FROM character_conditions WHERE name = 'Dying';"
  • Step 5: Commit
git add server/src/routes/initiative.ts
git commit -m "feat: tick Dying timer on party turn; mark is_dead when timer expires"

Task 5: death:recovery-roll socket handler

Files:

  • Modify: server/src/socket.ts

New socket event emitted by the client when the DM clicks Roll Recovery. Verifies DM role, verifies character is Dying, rolls d20, logs it as a "Death Save" roll, and on 18+ heals to 1 HP and clears the Dying condition.

  • Step 1: Add handler in socket.ts

Inside io.on("connection", ...), add after the atmosphere:update handler (before socket.on("disconnect", ...)):

socket.on("death:recovery-roll", async (data: {
  campaignId: number;
  characterId: number;
}) => {
  const userId = socket.data.user?.userId;

  // Verify caller is DM
  const [memberRows] = await db.execute<RowDataPacket[]>(
    "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?",
    [data.campaignId, userId]
  );
  if (memberRows.length === 0 || memberRows[0].role !== "dm") return;

  // Verify character has a Dying condition
  const [dyingRows] = await db.execute<RowDataPacket[]>(
    "SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
    [data.characterId]
  );
  if (dyingRows.length === 0) return;

  // Get character info for roll log
  const [charRows] = await db.execute<RowDataPacket[]>(
    "SELECT name, color, campaign_id FROM characters WHERE id = ?",
    [data.characterId]
  );
  if (charRows.length === 0) return;
  const char = charRows[0];

  // Roll d20 server-side
  const result = rollDice("1d20");
  const roll = result.total;
  const nat20 = roll === 20;
  const success = roll >= 18;

  // Log to roll_log with "Death Save" label (success gets suffix)
  const label = success ? "Death Save \u2014 stands at 1 HP!" : "Death Save";

  const [insertResult] = await db.execute<import("mysql2").ResultSetHeader>(
    `INSERT INTO roll_log
       (campaign_id, character_id, character_name, character_color, type, label,
        dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
     VALUES (?, ?, ?, ?, 'custom', ?, '1d20', ?, 0, ?, 0, 0, ?)`,
    [
      data.campaignId,
      data.characterId,
      char.name,
      char.color,
      label,
      JSON.stringify(result.rolls),
      roll,
      nat20 ? 1 : 0,
    ]
  );

  const [savedRows] = await db.execute<RowDataPacket[]>(
    "SELECT * FROM roll_log WHERE id = ?",
    [insertResult.insertId]
  );

  io.to(`campaign:${data.campaignId}`).emit("roll:result", {
    ...savedRows[0],
    rolls: result.rolls,
    advantage: false,
    disadvantage: false,
    nat20,
  });

  // On 18+: heal to 1 HP and clear Dying condition
  if (success) {
    await db.execute("UPDATE characters SET hp_current = 1 WHERE id = ?", [data.characterId]);
    await db.execute(
      "DELETE FROM character_conditions WHERE character_id = ? AND name = 'Dying'",
      [data.characterId]
    );

    const [updatedChar] = await db.execute<RowDataPacket[]>(
      "SELECT * FROM characters WHERE id = ?",
      [data.characterId]
    );
    const [updatedConditions] = await db.execute<RowDataPacket[]>(
      "SELECT * FROM character_conditions WHERE character_id = ?",
      [data.characterId]
    );
    io.to(`campaign:${data.campaignId}`).emit("character:updated", {
      ...updatedChar[0],
      conditions: updatedConditions,
    });
  }
});
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20

Expected: no errors.

  • Step 3: Commit
git add server/src/socket.ts
git commit -m "feat: death:recovery-roll socket handler — d20 save, 18+ stands at 1 HP"

Task 6: CharacterCard dying/dead UI

Files:

  • Modify: client/src/components/CharacterCard.tsx
  • Modify: client/src/components/CharacterCard.module.css

Dying state: pulsing red border + 💀N countdown in vitals row. Dead state: muted/greyed card, skull prefix on name, HP bar non-functional. Revive button: DM-only, appears when is_dead === true, sends PATCH { is_dead: false, hp_current: 1 }.

  • Step 1: Add CSS classes

Add to the end of client/src/components/CharacterCard.module.css:

.dying {
  border: 2px solid var(--danger) !important;
  animation: dyingPulse 1.5s ease-in-out infinite;
}

@keyframes dyingPulse {
  0%, 100% { box-shadow: 0 0 6px rgba(var(--danger-rgb), 0.4); }
  50%       { box-shadow: 0 0 20px rgba(var(--danger-rgb), 0.85); }
}

.dead {
  opacity: 0.45;
  filter: grayscale(0.75);
}

.dyingLabel {
  font-size: 0.8rem;
  color: var(--danger);
  font-weight: 700;
  flex-shrink: 0;
  white-space: nowrap;
}

.reviveBtn {
  display: block;
  width: 100%;
  margin-top: 0.5rem;
  padding: 0.25rem 0.5rem;
  font-size: 0.75rem;
  background: transparent;
  border: 1px solid var(--gold);
  color: var(--gold);
  border-radius: 3px;
  cursor: pointer;
  font-family: "Cinzel", Georgia, serif;
  letter-spacing: 0.04em;
}

.reviveBtn:hover {
  background: rgba(var(--gold-rgb), 0.12);
}
  • Step 2: Update CharacterCard component

Replace the entire contents of client/src/components/CharacterCard.tsx:

import type { Character } from "../types.js";
import HpBar from "./HpBar.js";
import TorchTimer from "./TorchTimer.js";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac.js";
import { getTalentHpBonus } from "../utils/talent-effects.js";
import { getEffectiveStat } from "../utils/talent-effects.js";
import { getModifier, formatModifier } from "../utils/modifiers.js";

function getAvatarUrl(character: Character): string {
  const style = (character.overrides?.avatar_style as string) || "micah";
  const seed = encodeURIComponent(character.name || "hero");
  return `https://api.dicebear.com/9.x/${style}/svg?seed=${seed}`;
}

const STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];

interface CharacterCardProps {
  character: Character;
  onHpChange: (characterId: number, hp: number) => void;
  onUpdate: (characterId: number, data: Partial<Character>) => void;
  onClick: (characterId: number) => void;
  canEdit?: boolean;
  focusSpell?: string;
  isDM?: boolean;
}

export default function CharacterCard({
  character,
  onHpChange,
  onUpdate,
  onClick,
  canEdit = true,
  focusSpell,
  isDM = false,
}: CharacterCardProps) {
  const dyingCondition = character.conditions?.find((c) => c.name === "Dying");
  const isDying = !!dyingCondition;
  const isDead = !!character.is_dead;

  const cardClass = [
    styles.card,
    isDying ? styles.dying : "",
    isDead ? styles.dead : "",
  ]
    .filter(Boolean)
    .join(" ");

  // When dying/dead the left-color border is replaced by the dying/dead CSS
  const cardStyle = isDying || isDead
    ? {}
    : { borderLeftColor: character.color, borderLeftWidth: "3px" };

  return (
    <div className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
      <div className={styles.cardHeader}>
        <img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
        <div className={styles.nameRow}>
          <span className={styles.name}>
            {isDead ? "\u{1F480} " : ""}{character.name}
            {character.title ? ` ${character.title}` : ""}
          </span>
          <span className={styles.level}>Lvl {character.level}</span>
        </div>
      </div>

      <div className={styles.meta}>
        {character.ancestry} {character.class}
      </div>

      {focusSpell && (
        <div className={styles.focusIndicator}>
          &#9679; Focusing: {focusSpell}
        </div>
      )}

      <div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
        <HpBar
          current={character.hp_current}
          max={character.hp_max + getTalentHpBonus(character)}
          onChange={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
        />
        {isDying && dyingCondition && (
          <span className={styles.dyingLabel} title="Dying">
            {"\u{1F480}"} {dyingCondition.rounds_remaining}
          </span>
        )}
        <div className={styles.ac}>
          <span className={styles.acLabel}>AC</span>
          <span className={styles.acValue}>{calculateAC(character).effective}</span>
        </div>
        <span
          className={styles.luck}
          title={character.luck_token ? "Luck available" : "Luck spent"}
        >
          {character.luck_token ? "\u2605" : "\u2606"}
        </span>
        <TorchTimer
          torchLitAt={character.torch_lit_at}
          onToggle={() => {
            const isLit = character.torch_lit_at !== null;
            onUpdate(character.id, {
              torch_lit_at: isLit ? null : new Date().toISOString(),
            } as Partial<Character>);
          }}
        />
      </div>

      {isDead && isDM && (
        <button
          className={styles.reviveBtn}
          onClick={(e) => {
            e.stopPropagation();
            onUpdate(character.id, { is_dead: false, hp_current: 1 } as Partial<Character>);
          }}
        >
          Revive
        </button>
      )}

      <div className={styles.modRow}>
        {STATS.map((stat) => {
          const value = getEffectiveStat(character, stat);
          const mod = getModifier(value);
          return (
            <span key={stat} className={styles.mod}>
              <span className={styles.modLabel}>{stat}</span>
              <span className={styles.modValue}>{formatModifier(mod)}</span>
            </span>
          );
        })}
      </div>

      <div className={styles.xp}>
        XP {character.xp} / {character.level * 10}
      </div>
    </div>
  );
}

Note: The original imports use no .js extension on the type import (from "../types") and no extension on some component imports. The existing codebase uses .js for some and none for others in the same file — keep whatever the original had for the imports that existed before, and use .js on any new ones. The existing file used no extensions — keep that pattern.

Actually, looking at the original file:

import type { Character } from "../types";
import HpBar from "./HpBar";
import TorchTimer from "./TorchTimer";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac";
...

Use the same no-extension pattern to match existing code in this file:

import type { Character } from "../types";
import HpBar from "./HpBar";
import TorchTimer from "./TorchTimer";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac";
import { getTalentHpBonus } from "../utils/talent-effects";
import { getEffectiveStat } from "../utils/talent-effects";
import { getModifier, formatModifier } from "../utils/modifiers";

Use those exact import paths (no .js) in the replacement.

  • Step 3: Verify CharacterCard callers still compile

Search for all places that render <CharacterCard to confirm the new optional isDM prop doesn't break anything:

grep -rn "CharacterCard" /Users/aaron.wood/workspace/shadowdark/client/src/

All existing call sites are compatible — isDM is optional and defaults to false.

Now find where the DM view renders cards and pass isDM:

grep -rn "onHpChange\|CharacterCard" /Users/aaron.wood/workspace/shadowdark/client/src/pages/

In CampaignView.tsx (or wherever the DM renders character cards), add isDM={role === "dm"} or isDM={isDM} to the <CharacterCard> calls.

  • Step 4: Verify TypeScript compiles
cd client && npx tsc --noEmit 2>&1 | head -30

Expected: no errors.

  • Step 5: Commit
git add client/src/components/CharacterCard.tsx client/src/components/CharacterCard.module.css
git commit -m "feat: CharacterCard dying pulse border + countdown; dead state; DM Revive button"

Task 7: InitiativeTracker Roll Recovery button

Files:

  • Modify: client/src/components/InitiativeTracker.tsx

In the active combat view, for each party combatant with a Dying condition, show a "💀 Dying (N rounds)" label and a DM-only "Roll Recovery" button that emits death:recovery-roll.

  • Step 1: Add emitRecoveryRoll in InitiativeTracker

In InitiativeTracker (the main component, around line 50), add:

function emitRecoveryRoll(characterId: number) {
  socket.emit("death:recovery-roll", { campaignId, characterId });
}
  • Step 2: Pass onRecoveryRoll to ActivePhase

Update the <ActivePhase> call (around line 107) to pass the new callback:

<ActivePhase
  combat={combat}
  partyChars={partyChars}
  isDM={isDM}
  showAddEnemy={showAddEnemy}
  addEnemyName={addEnemyName}
  addEnemyHp={addEnemyHp}
  onSetShowAddEnemy={setShowAddEnemy}
  onSetAddEnemyName={setAddEnemyName}
  onSetAddEnemyHp={setAddEnemyHp}
  onUpdateEnemyHp={emitUpdateEnemyHp}
  onRemoveEnemy={emitRemoveEnemy}
  onAddEnemy={emitAddEnemy}
  onNext={emitNext}
  onEnd={emitEnd}
  onRecoveryRoll={emitRecoveryRoll}
/>
  • Step 3: Update ActivePhaseProps interface

In the ActivePhaseProps interface (around line 220), add:

onRecoveryRoll: (characterId: number) => void;
  • Step 4: Update ActivePhase destructuring

In the ActivePhase function signature, add onRecoveryRoll to the destructuring.

  • Step 5: Add dying indicator and Roll Recovery button in party list

In ActivePhase, replace the party combatant render (the partyChars.map block around line 266) with:

{partyChars.map((c) => {
  const dyingCondition = c.conditions?.find((cond) => cond.name === "Dying");
  return (
    <div key={c.id} className={styles.combatantRow}>
      <span className={styles.dot} style={{ background: c.color }} />
      <span className={partyActive ? styles.activeName : styles.rollName}>
        {c.is_dead ? "\u{1F480} " : ""}{c.name}
      </span>
      {dyingCondition && (
        <span className={styles.dyingTag}>
          {"\u{1F480}"} Dying ({dyingCondition.rounds_remaining}r)
        </span>
      )}
      {isDM && dyingCondition && !c.is_dead && (
        <button
          className={styles.recoveryBtn}
          onClick={() => onRecoveryRoll(c.id)}
        >
          Roll Recovery
        </button>
      )}
    </div>
  );
})}
  • Step 6: Add CSS for dying tag and recovery button

Open client/src/components/InitiativeTracker.module.css and add:

.dyingTag {
  font-size: 0.72rem;
  color: var(--danger);
  font-weight: 600;
  margin-left: auto;
  white-space: nowrap;
}

.recoveryBtn {
  font-size: 0.7rem;
  padding: 0.15rem 0.4rem;
  background: transparent;
  border: 1px solid var(--danger);
  color: var(--danger);
  border-radius: 3px;
  cursor: pointer;
  white-space: nowrap;
  flex-shrink: 0;
}

.recoveryBtn:hover {
  background: rgba(var(--danger-rgb), 0.12);
}
  • Step 7: Verify TypeScript compiles
cd client && npx tsc --noEmit 2>&1 | head -30

Expected: no errors.

  • Step 8: End-to-end smoke test
  1. Start server and client
  2. Log in as DM, open a campaign with a character in active combat
  3. Set character HP to 0 → card gains red pulsing border with 💀N
  4. Advance turn (enemy → party) → rounds_remaining decrements
  5. Click Roll Recovery in initiative tracker → roll appears in roll log
  6. On a 18+ roll → character HP returns to 1, dying border disappears
  7. Let timer expire → character marked dead → card greyed with skull, HP non-functional
  8. Click Revive → character returns to 1 HP, dead state clears
  • Step 9: Commit
git add client/src/components/InitiativeTracker.tsx client/src/components/InitiativeTracker.module.css
git commit -m "feat: dying label and Roll Recovery button in InitiativeTracker active phase"

Self-Review Checklist

Spec coverage:

  • HP→0 auto-inserts Dying condition (Task 3)
  • HP>0 clears Dying condition (Task 3)
  • 1d4 + CON modifier, minimum 1 (Task 3)
  • is_dead column (Task 1)
  • Party turn ticks the timer (Task 4)
  • Timer expiry sets is_dead + deletes condition (Task 4)
  • death:recovery-roll event, DM only (Task 5)
  • d20 server-side, roll:result with "Death Save" label (Task 5)
  • 18+ heals to 1 HP, clears Dying (Task 5)
  • Pulsing red border + 💀N on dying card (Task 6)
  • Grey/muted dead card, HP non-functional (Task 6)
  • Revive button (DM only, Task 6)
  • Dying label + Roll Recovery button in InitiativeTracker (Task 7)
  • Roll log "Death Save" with success suffix (Task 5)
  • Already dying: no new condition (Task 3 — checks isAlreadyDying)
  • Negative CON mod: clamped to minimum 1 (Task 3 — Math.max(1, ...))
  • Combat ends while dying: timer just stops ticking (tick only happens in initiative:next)
  • Multiple dying chars same round: loop handles all in tickDeathTimers (Task 4)

Scope check: All changes are narrowly scoped to the death timer feature. No unrelated refactors.

Type consistency:

  • conditions: Condition[] added to Character in types.ts
  • is_dead: boolean added to Character in types.ts
  • Both are included in character:updated broadcasts from all code paths
  • Condition.rounds_remaining is number | null in types — initiative handler casts to number after confirming dyingRows.length > 0