Compare commits

..

No commits in common. "9607462a7c6843e361200f6f8cd9a3d213c7a926" and "0cf3e4583c8bf6978de65321347cb8be85a60b73" have entirely different histories.

12 changed files with 25 additions and 347 deletions

View file

@ -9,11 +9,6 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
## [Unreleased]
## [0.4.0] - 2026-04-12
### Added
- **Death timer** — when a character drops to 0 HP they enter a dying state with a 1d4 + CON modifier round countdown (minimum 1). The character card gains a pulsing red border and shows the rounds remaining. Each party turn the timer ticks down automatically when initiative advances. The DM can roll a d20 recovery save from the initiative tracker; an 18 or higher lets the character stand at 1 HP. When the timer expires the character is marked permanently dead (greyed-out card, locked HP). A DM-only Revive button resets the character to 1 HP if story reasons bring them back.
## [0.3.0] - 2026-04-11
### Added

View file

@ -153,58 +153,3 @@
color: var(--text-tertiary);
text-align: right;
}
.dying {
border: 2px solid var(--danger) !important;
animation: dyingPulse 1.5s ease-in-out infinite;
transition: none;
}
@keyframes dyingPulse {
0%, 100% {
box-shadow:
0 4px 12px rgba(var(--shadow-rgb), 0.5),
0 1px 4px rgba(var(--shadow-rgb), 0.3),
inset 0 1px 0 rgba(var(--gold-rgb), 0.06),
0 0 6px rgba(var(--danger-rgb), 0.4);
}
50% {
box-shadow:
0 4px 12px rgba(var(--shadow-rgb), 0.5),
0 1px 4px rgba(var(--shadow-rgb), 0.3),
inset 0 1px 0 rgba(var(--gold-rgb), 0.06),
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);
}

View file

@ -22,7 +22,6 @@ interface CharacterCardProps {
onClick: (characterId: number) => void;
canEdit?: boolean;
focusSpell?: string;
isDM?: boolean;
}
export default function CharacterCard({
@ -32,42 +31,26 @@ export default function CharacterCard({
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.card}
onClick={() => onClick(character.id)}
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
>
<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.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}
@ -78,16 +61,13 @@ export default function CharacterCard({
<HpBar
current={character.hp_current}
max={character.hp_max + getTalentHpBonus(character)}
onChange={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
onChange={(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>
<span className={styles.acValue}>
{calculateAC(character).effective}
</span>
</div>
<span
className={styles.luck}
@ -106,18 +86,6 @@ export default function CharacterCard({
/>
</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);

View file

@ -302,28 +302,3 @@
border-color: rgba(var(--gold-rgb), 0.5);
color: var(--gold);
}
.dyingTag {
font-size: 0.72rem;
color: var(--danger);
font-weight: 600;
margin-left: auto;
white-space: nowrap;
flex-shrink: 0;
}
.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);
}

View file

@ -56,10 +56,6 @@ export default function InitiativeTracker({
socket.emit("initiative:end", { campaignId, combatId: combat.id });
}
function emitRecoveryRoll(characterId: number) {
socket.emit("death:recovery-roll", { campaignId, characterId });
}
function emitUpdateEnemyHp(enemyId: string, hp_current: number) {
socket.emit("initiative:update-enemy", {
campaignId,
@ -123,7 +119,6 @@ export default function InitiativeTracker({
onAddEnemy={emitAddEnemy}
onNext={emitNext}
onEnd={emitEnd}
onRecoveryRoll={emitRecoveryRoll}
/>
)}
</div>
@ -237,7 +232,6 @@ interface ActivePhaseProps {
onAddEnemy: () => void;
onNext: () => void;
onEnd: () => void;
onRecoveryRoll: (characterId: number) => void;
}
function ActivePhase({
@ -255,7 +249,6 @@ function ActivePhase({
onAddEnemy,
onNext,
onEnd,
onRecoveryRoll,
}: ActivePhaseProps) {
const partyActive = combat.current_side === "party";
@ -270,30 +263,14 @@ function ActivePhase({
</span>
)}
</div>
{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 && !c.is_dead && (
<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>
);
})}
{partyChars.map((c) => (
<div key={c.id} className={styles.combatantRow}>
<span className={styles.dot} style={{ background: c.color }} />
<span className={partyActive ? styles.activeName : styles.rollName}>
{c.name}
</span>
</div>
))}
</div>
<div className={`${styles.section} ${!partyActive ? styles.activeSection : ""}`}>

View file

@ -312,8 +312,7 @@ export default function CampaignView() {
}
async function handleHpChange(characterId: number, hp: number) {
const clampedHp = Math.max(0, hp);
await updateCharacter(characterId, { hp_current: clampedHp });
await updateCharacter(characterId, { hp_current: hp });
}
async function handleStatChange(
@ -454,7 +453,6 @@ export default function CampaignView() {
onClick={setSelectedId}
canEdit={role === "dm" || char.user_id === user?.userId}
focusSpell={focusSpells.get(char.id)}
isDM={role === "dm"}
/>
))}
</div>

View file

@ -1,6 +1,6 @@
# Darkwatch Handbook
**Last updated:** 2026-04-12
**Last updated:** 2026-04-11
A reference for everyone working on or with Darkwatch — whether you're a DM discovering the tool, a developer joining the project, or a collaborator adding features.
@ -104,19 +104,6 @@ Shadowdark uses team-based initiative — the whole party acts together, then al
Combat state persists to the database. If the server restarts mid-fight, the tracker reappears when players rejoin.
### Death Timer
Shadowdark's dying mechanic is fully implemented. When a character drops to 0 HP they enter a dying state:
- The server rolls 1d4 + CON modifier (minimum 1) to determine how many rounds the character has left
- The character card gains a pulsing red border and shows a 💀 countdown (e.g. 💀 3)
- Each time the party's turn begins in the initiative tracker, all dying timers tick down by 1
- The DM can click **Roll Recovery** in the initiative tracker — a d20 is rolled server-side; 18 or higher lets the character stand at 1 HP
- If the timer reaches 0 the character is marked permanently dead (card goes grey, HP is locked)
- The DM can click **Revive** on a dead character's card to bring them back at 1 HP
Healing a dying character above 0 HP at any point immediately clears the dying state with no recovery roll needed.
### DM View
The DM sees all characters in a compact three-column card grid showing: HP (current/max), AC, luck token state, torch timer, and all stat modifiers at a glance. The full character detail is one click away.
@ -126,7 +113,8 @@ The DM sees all characters in a compact three-column card grid showing: HP (curr
Features currently planned or in progress:
- **Talent roll UI** — roll 2d6 on the class talent table when leveling up; currently talents are added manually
- **Conditions / status effects** — poisoned, stunned, etc. shown on character cards (dying is already tracked internally)
- **Death timer** — when a character is dying: 1d4 + CON rounds to live, d20 each turn to rise at 1 HP
- **Conditions / status effects** — poisoned, stunned, dying, etc. shown on character cards
- **Per-enemy dice rolls** — DM triggers attack/damage rolls from individual enemies in the tracker
- **Party loot / shared inventory** — campaign-level shared item pool, DM-managed
- **Creature gallery / NPC system** — saved enemy stat blocks, quick-add to initiative tracker

View file

@ -721,17 +721,9 @@ router.post("/:id/rest", requireAuth, async (req, res, next) => {
"DELETE FROM character_conditions WHERE character_id = ?",
[req.params.id]
);
const [charRow] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE id = ?",
[req.params.id]
);
const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(charRow[0].campaign_id), "character:updated", {
...charRow[0],
overrides: parseJson(charRow[0].overrides),
conditions: [],
});
io.to(String(charRow[0].campaign_id)).emit("character:rested", { characterId: Number(req.params.id) });
const io = req.app.get("io");
const char = await getCharacterCampaignId(Number(req.params.id));
io.to(String(char.campaign_id)).emit("character:rested", { characterId: Number(req.params.id) });
res.json({ ok: true });
} catch (err) { next(err); }
});

View file

@ -154,109 +154,6 @@ export function setupSocket(io: Server) {
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
});
socket.on("death:recovery-roll", async (data: {
campaignId: number;
characterId: number;
}) => {
try {
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;
// Verify character is not permanently dead
const [deadCheck] = await db.execute<RowDataPacket[]>(
"SELECT is_dead FROM characters WHERE id = ?",
[data.characterId]
);
if (deadCheck.length === 0 || deadCheck[0].is_dead) 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];
if (char.campaign_id !== data.campaignId) return;
// 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, subtype, label,
dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
VALUES (?, ?, ?, ?, 'custom', 'death-save', ?, '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,
});
}
} catch (err) {
console.error("[death:recovery-roll]", err);
}
});
socket.on("disconnect", () => {
// Rooms are cleaned up automatically by Socket.IO
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 KiB

View file

@ -145,23 +145,6 @@
</div>
</div>
<!-- Row 8: text left, screenshot right -->
<div class="feature-row reverse">
<div class="feature-image">
<img src="assets/screenshots/death-timer.png" alt="Initiative tracker showing a dying character with skull countdown and Roll Recovery button">
</div>
<div class="feature-text">
<div class="feature-label">Death Timer</div>
<h2 class="feature-heading">Every round could be the last.</h2>
<p class="feature-desc">
When a character drops to 0 HP, a countdown begins: 1d4 + CON modifier rounds
before permanent death. The DM sees the timer on the card and can roll a d20
recovery save from the initiative tracker — an 18 or higher lets the character
stand at 1 HP. Run out the clock and they're gone for good.
</p>
</div>
</div>
<!-- Torch + Luck callout cards (no screenshot) -->
<div class="callout-row">
<div class="callout-card">

View file

@ -245,45 +245,6 @@ async function captureInitiativeActive(page) {
// Screenshot captures the initiative tracker with party + enemies rolled
}
async function captureDeathTimer(page) {
await resetToCleanCampaign(page);
// Start combat with an enemy so the initiative tracker is visible
await page.click('button:has-text("Combat")');
await page.waitForTimeout(400);
const enemyNameInput = page.locator('input[placeholder*="Goblin"], input[placeholder*="enemy" i], input[placeholder*="name" i]').first();
if (await enemyNameInput.isVisible().catch(() => false)) {
await enemyNameInput.fill('Skeleton');
const hpInput = page.locator('input[placeholder*="HP"], input[placeholder*="hp" i], input[type="number"]').first();
if (await hpInput.isVisible().catch(() => false)) {
await hpInput.fill('8');
}
}
// Roll initiative to enter the active phase
await page.click('button:has-text("Roll Initiative")');
await page.waitForTimeout(1500);
// Bring the first character to 0 HP via the API — server rolls dying timer automatically
await page.evaluate(async () => {
const campaignId = Number(location.pathname.match(/\/campaign\/(\d+)/)?.[1]);
const res = await fetch(`/api/campaigns/${campaignId}/characters`, { credentials: 'include' });
const chars = await res.json();
if (chars.length > 0) {
await fetch(`/api/characters/${chars[0].id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hp_current: 0 }),
});
}
});
await page.waitForTimeout(1500); // wait for socket broadcast + React re-render
// Screenshot shows initiative tracker with dying character (skull tag + Roll Recovery button)
// and DM card with pulsing red border + skull countdown
}
// ── main ───────────────────────────────────────────────────────────────────
async function run() {
@ -312,7 +273,6 @@ async function run() {
await shot(page, 'spellcasting', captureSpellcasting);
await shot(page, 'atmosphere', captureAtmosphere);
await shot(page, 'initiative-active', captureInitiativeActive);
await shot(page, 'death-timer', captureDeathTimer);
await browser.close();
console.log('');