- Add status-driven death-save model: - status: conscious | dying | stable | dead - deathSaveSuccesses/deathSaveFailures as display counters - remove old death-save fields as source of truth - Add death-save actions: - success, fail, nat1, nat20 - stabilize participant - revive dead participant to 0 HP, stable, unconscious - Apply 5e HP transition rules: - characters and NPCs at 0 HP become dying - stable/dying participants gain unconscious condition - healing clears death saves and returns conscious - massive damage kills - non-NPC monsters at 0 HP become dead and inactive - Split NPCs from monsters with type="npc": - NPCs use death saves like characters - NPCs remain DM-controlled/monster-colored in UI - new NPCs no longer persist isNpc flag - Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates - Allow DM active/inactive toggle for dead participants - Hide inactive participants from player display - Improve DM and player death-state UI: - show Dying/Dead/Unconscious states consistently - add revive button for dead participants - distinguish dead visual from inactive visual in DM view - Fix initiative drag/reorder behavior: - downward drag now changes order instead of no-op - paused combat can reorder across current turn pointer - turnOrderIds stay synced to participants order - Persist campaign collapse state in localStorage - Update death-save docs and encounter builder docs - Add/expand tests for: - death saves and HP transitions - dead/active/inactive behavior - NPC death-save behavior - player display visibility - drag reorder semantics - logging expectations Tests: - npm run test:all - app: 94 passed - shared: 158 passed, 5 skipped - server: 32 passed
8.8 KiB
Encounter Builder — DM Interface Guide
How a DM (or LLM automating the DM role) builds and runs encounters via the UI and storage layer. Covers entity model, build flow, combat controls, and the storage paths backing each action.
Entity model
Three nested entities. All stored as opaque JSON docs in the KV store (generic doc store — see docs/DEVELOPMENT.md).
Campaign
└─ Encounter(s)
└─ Participant(s)
Plus two global docs:
activeDisplay/status— controls player view (which campaign+encounter, hide-HP flag)logs/{id}— append-only action log entries
Campaign
Path: artifacts/{APP_ID}/public/data/campaigns/{campaignId}
| Field | Type | Notes |
|---|---|---|
name |
string | |
playerDisplayBackgroundUrl |
string | optional, image URL for player display bg |
ownerId |
string | user id |
createdAt |
ISO string | |
players |
array | campaign-level character roster (templates, NOT combatants) |
Campaign characters = reusable templates. Default HP + init mod. Added to any encounter via ParticipantManager. Not combatants themselves.
Encounter
Path: artifacts/{APP_ID}/public/data/campaigns/{campaignId}/encounters/{encounterId}
| Field | Type | Notes |
|---|---|---|
name |
string | |
createdAt |
ISO string | |
participants |
array | the combatants (see below) |
round |
int | 0 = not started |
currentTurnParticipantId |
string|null | who acts now |
isStarted |
bool | combat active |
isPaused |
bool | frozen turn order (add/remove/edit allowed) |
turnOrderIds |
array | participant ids in turn order = participants[] order (1-list model) |
Participant
Object in encounter.participants[]:
| Field | Type | Notes |
|---|---|---|
id |
string | generateId() |
name |
string | |
type |
'character' | 'npc' | 'monster' |
character/NPC = death saves, monster = dead at 0 HP |
originalCharacterId |
string|null | links back to campaign character if type=character |
initiative |
int | rolled once at add (rollD20() + mod). Stored value, not re-derived. |
maxHp |
int | |
currentHp |
int | 0 = dead/dying/stable by status |
conditions |
array | condition ids from CONDITIONS list |
isActive |
bool | in turn rotation? false = skipped by nextTurn |
status |
'conscious' | 'dying' | 'stable' | 'dead' |
death-save source of truth |
deathSaveSuccesses |
int | character/NPC only, 0-3 successes |
deathSaveFailures |
int | character/NPC only, 0-3 failures |
Build flow (UI)
Admin view at /. Steps:
1. Create campaign
- Click Create Campaign button
- Enter name + optional background URL
- Submits →
setDoc(campaigns/{id}, { name, playerDisplayBackgroundUrl, ownerId, createdAt, players:[] })
2. Select campaign
- Click campaign card →
setSelectedCampaignId(campaign.id) - Now managing: CharacterManager + EncounterManager visible
3. Add campaign characters (optional templates)
CharacterManager section. Per character:
- Name
- Default HP (
DEFAULT_MAX_HP= 10) - Init Mod (
DEFAULT_INIT_MOD= 0)
→ updateDoc(campaign, { players:[...existing, newChar] })
These are reusable across encounters. Add to encounter later (auto-rolls initiative).
4. Create encounter
- Click Create Encounter
- Enter name
→
setDoc(campaigns/{cid}/encounters/{eid}, { name, createdAt, participants:[], round:0, currentTurnParticipantId:null, isStarted:false, isPaused:false })
5. Add participants
ParticipantManager section. Two paths:
Monster/NPC:
- Monster Name (
placeholder: "e.g., Dire Wolf") - Init Mod (
MONSTER_DEFAULT_INIT_MOD= 2) - Max HP (
DEFAULT_MAX_HP= 10) - Is NPC? checkbox (sets
type: 'npc', keeps monster/DM-control color, enables death saves) - Click Add to Encounter
- Initiative auto-rolled:
rollD20() + mod
Character (from campaign roster):
- Select character from dropdown
- Click Add to Encounter
- OR Add All (Roll Init) — bulk-adds all campaign chars, each rolls own initiative
Duplicate guard: same originalCharacterId blocked (alerts "already in this encounter"). Monsters no dedup.
Participant object added:
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 }
6. Reorder before start (tie-break)
Pre-combat only (!isStarted || isPaused). Drag handles shown for tied initiative values only. Drop reorders participants[] + turnOrderIds.
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
Combat flow (UI)
InitiativeControls panel (sticky, right side).
Start
- Start Combat button (disabled if no active participants)
- Sorts ALL participants by initiative (1-list:
participants[]= display + turn order) round=1,currentTurnParticipantId= first active,isStarted=true,isPaused=false- Sets
activeDisplay→ this campaign+encounter (player display syncs) - Initiative fixed at start. NOT re-derived from mod after.
Next Turn
- Next Turn button (disabled if paused)
- Advances to next active participant in
turnOrderIds - Wraps at end →
round += 1 - No re-sort after start; 1-list order remains source of truth
- Inactive (
isActive:false) participants are skipped, but stay in slot
Pause / Resume
- Pause Combat →
isPaused=true, Next Turn disabled - While paused: add/remove participants, adjust HP, edit initiative, reorder ties
- Resume Combat →
isPaused=false, no re-sort (1-list: turnOrderIds already current)
HP adjustments (combat only)
Per-participant input + buttons:
- Number input
- Damage (HeartCrack icon) —
currentHp = max(0, hp - amt) - Heal (Heart icon) —
currentHp = min(maxHp, hp + amt)for living/dying/stable participants; normal heal does not affect dead participants - Character/NPC at 0 HP →
status:'dying', death-save controls, unconscious condition - Monster at 0 HP →
status:'dead',isActive:false - Massive damage (
damage >= currentHp + maxHp) → dead
Death saves (character/NPC only, at 0 HP)
Action buttons: Success, Fail, Nat1, Nat20, Stabilize.
- Success: +1 success; 3 successes → stable/unconscious at 0 HP
- Fail: +1 failure; 3 failures → dead
- Nat1: +2 failures
- Nat20: 1 HP, conscious, counters reset
- Stabilize: stable/unconscious at 0 HP, counters reset
- Revive: dead → 0 HP, stable/unconscious, active
Conditions
- Click participant → expand conditions picker (all 22 from
CONDITIONS) - Active conditions show as badges, click to remove
End combat
- End Combat button → resets
isStarted:false,round:0,currentTurn:null,turnOrderIds:[] - Clears
activeDisplay(player view goes blank)
Player display
Separate view at /display or ?playerView=true. Read-only second screen.
What it shows:
- Current encounter name
- Round + current turn participant
- All participants in
participants[]order (drag order, NOT init-sorted — BUG-15 fix) - HP bars, conditions, death saves
- Inactive participants hidden (including auto-inactive dead monsters and DM-hidden reserves)
Driven by activeDisplay/status doc. Controlled by Open Player Window button (sets active campaign+encounter) or Start Combat (auto-sets).
1-list turn order model
Key architecture. turnOrderIds === participants.map(p => p.id) always. Single source of truth.
- Display =
participants[]order (AdminView + DisplayView, no re-sort) - Turn rotation =
turnOrderIds(mirrors participants[]) - Drag = source of truth, overrides initiative
- Add mid-combat = slot by initiative into participants[] + sync
- Toggle active = flip
isActiveonly, stay in slot - Remove = drop from participants[] + sync, advance current if needed
No re-sort after startEncounter.
Storage paths quick reference
campaigns/{cid} campaign doc
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
campaigns/{cid}/encounters/{eid}/participants ❌ NOT a path — participants inline
activeDisplay/status player display control
logs/{logId} action log entry
DM tips
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
- Dead monsters become inactive automatically and are skipped. Remove via trash icon to clean list.
- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them.
- Player display auto-follows Start Combat. Manual control via Open Player Window.
See docs/GLOSSARY.md for domain terms, TODO.md for known bugs.