# 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`: ```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): ```bash cd server && npm run dev ``` Expected: server logs `Running migration: 004_death_timer.sql` then `Migrations complete.` - [ ] **Step 3: Verify column exists** ```bash 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** ```bash 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 51–75 of `server/src/routes/characters.ts`) — add a conditions fetch and include it in the return value: ```typescript async function enrichCharacters(characters: RowDataPacket[]) { return Promise.all( characters.map(async (char) => { const [stats] = await db.execute( "SELECT stat_name, value FROM character_stats WHERE character_id = ?", [char.id] ); const [gear] = await db.execute( "SELECT * FROM character_gear WHERE character_id = ?", [char.id] ); const [talents] = await db.execute( "SELECT * FROM character_talents WHERE character_id = ?", [char.id] ); const [conditions] = await db.execute( "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 215–227 of `server/src/routes/characters.ts`) fetches `SELECT * FROM characters` then builds an enriched object. Replace that block to also fetch conditions: ```typescript const [rows] = await db.execute( "SELECT * FROM characters WHERE id = ?", [id] ); const [conditions] = await db.execute( "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`): ```typescript 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; 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** ```bash cd client && npx tsc --noEmit 2>&1 | head -30 ``` Expected: no new errors. (Pre-existing errors unrelated to this feature are OK.) ```bash cd server && npx tsc --noEmit 2>&1 | head -30 ``` Expected: no new errors. - [ ] **Step 5: Commit** ```bash 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: ```typescript 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"`: ```typescript 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: ```typescript // 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( "SELECT id FROM character_conditions WHERE character_id = ? AND name = 'Dying'", [id] ); const [deadRows] = await db.execute( "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( "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** ```bash 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: ```bash 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** ```bash 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`: ```typescript async function tickDeathTimers(io: Server, campaignId: number): Promise { // Find all living characters in this campaign with a Dying condition const [charRows] = await db.execute( "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( `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( "SELECT * FROM characters WHERE id = ?", [condition.character_id] ); const [updatedConditions] = await db.execute( "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: ```typescript // 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** ```bash 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: ```bash docker exec -it darkwatch-maria mariadb -u darkwatch -pdarkwatch darkwatch \ -e "SELECT * FROM character_conditions WHERE name = 'Dying';" ``` - [ ] **Step 5: Commit** ```bash 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", ...)`): ```typescript 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( "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( "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( "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( `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( "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( "SELECT * FROM characters WHERE id = ?", [data.characterId] ); const [updatedConditions] = await db.execute( "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** ```bash cd server && npx tsc --noEmit 2>&1 | head -20 ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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`: ```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`: ```typescript 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) => 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 (
onClick(character.id)} style={cardStyle}>
{isDead ? "\u{1F480} " : ""}{character.name} {character.title ? ` ${character.title}` : ""} Lvl {character.level}
{character.ancestry} {character.class}
{focusSpell && (
● Focusing: {focusSpell}
)}
e.stopPropagation()}> {} : (hp) => onHpChange(character.id, hp)} /> {isDying && dyingCondition && ( {"\u{1F480}"} {dyingCondition.rounds_remaining} )}
AC {calculateAC(character).effective}
{character.luck_token ? "\u2605" : "\u2606"} { const isLit = character.torch_lit_at !== null; onUpdate(character.id, { torch_lit_at: isLit ? null : new Date().toISOString(), } as Partial); }} />
{isDead && isDM && ( )}
{STATS.map((stat) => { const value = getEffectiveStat(character, stat); const mod = getModifier(value); return ( {stat} {formatModifier(mod)} ); })}
XP {character.xp} / {character.level * 10}
); } ``` 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: ```typescript 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: ```typescript 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 `` calls. - [ ] **Step 4: Verify TypeScript compiles** ```bash cd client && npx tsc --noEmit 2>&1 | head -30 ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash 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: ```typescript function emitRecoveryRoll(characterId: number) { socket.emit("death:recovery-roll", { campaignId, characterId }); } ``` - [ ] **Step 2: Pass onRecoveryRoll to ActivePhase** Update the `` call (around line 107) to pass the new callback: ```tsx ``` - [ ] **Step 3: Update ActivePhaseProps interface** In the `ActivePhaseProps` interface (around line 220), add: ```typescript 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: ```tsx {partyChars.map((c) => { const dyingCondition = c.conditions?.find((cond) => cond.name === "Dying"); return (
{c.is_dead ? "\u{1F480} " : ""}{c.name} {dyingCondition && ( {"\u{1F480}"} Dying ({dyingCondition.rounds_remaining}r) )} {isDM && dyingCondition && !c.is_dead && ( )}
); })} ``` - [ ] **Step 6: Add CSS for dying tag and recovery button** Open `client/src/components/InitiativeTracker.module.css` and add: ```css .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** ```bash 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** ```bash 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`