30 KiB
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 51–75 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 215–227 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}>
● 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
- Start server and client
- Log in as DM, open a campaign with a character in active combat
- Set character HP to 0 → card gains red pulsing border with 💀N
- Advance turn (enemy → party) → rounds_remaining decrements
- Click Roll Recovery in initiative tracker → roll appears in roll log
- On a 18+ roll → character HP returns to 1, dying border disappears
- Let timer expire → character marked dead → card greyed with skull, HP non-functional
- 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_deadcolumn (Task 1) - ✅ Party turn ticks the timer (Task 4)
- ✅ Timer expiry sets is_dead + deletes condition (Task 4)
- ✅
death:recovery-rollevent, 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 toCharacterintypes.tsis_dead: booleanadded toCharacterintypes.ts- Both are included in
character:updatedbroadcasts from all code paths Condition.rounds_remainingisnumber | nullin types — initiative handler casts tonumberafter confirmingdyingRows.length > 0