Files
ttrpg-initiative-tracker/docs/ENCOUNTER_BUILDER.md
T
david raistrick 43b1f6ce41 WIP: add ttrpg-encounter-builder skill + server-mode API docs
UNTESTED work in progress. Do not assume correct.

Scope: SERVER BACKEND ONLY (SQLite/Express, REACT_APP_STORAGE=server).
Does NOT work with Firebase SDK mode — no HTTP backend there, different
transport/auth. Skill + doc explicitly call this out.

- .agents/skills/ttrpg-encounter-builder/: harness-agnostic skill
  (pi/claude/codex via .agents/skills + symlinks). SKILL.md + helper
  script that batch-writes encounters via REST, rolls initiative, verifies.
- docs/ENCOUNTER_BUILDER.md: add Path normalization section, Build flow
  (API/scripts) section with REST endpoint table, object templates,
  recipe. Server-mode-only caveat noted.

Helper script syntax-checked + campaign lookup verified against running
instance, but full seed flow not regression-tested against test suite.
2026-07-07 15:42:02 -04:00

13 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

Path normalization

App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/campaigns/...). Server-mode adapter norm() strips prefix → bare canonical (campaigns/...).

  • UI / app code → prefixed paths (getPath.* helpers)
  • REST API / direct DB / scripts → bare canonical paths (campaigns/...)

Mixing = empty results. If GET /api/collection?path=artifacts/.../campaigns returns [], you used the prefixed form. Drop the prefix.

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.

Build flow (API / scripts)

Same logical steps as UI, via REST. For LLM agents or seed scripts.

Server mode only. REST endpoints (/api/*) exist only when tracker runs with REACT_APP_STORAGE=server (bundled Express backend mounted). Firebase SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK, different auth + write paths. This section does not apply to firebase mode.

Confirm server mode before scripting:

curl -s <HOST>/api/collection?path=campaigns | head -c 200

JSON array = server mode. HTML (SPA shell) = firebase mode or backend not mounted — API path won't work.

REST endpoints

Method Path Body / Query Action
GET /api/doc ?path= read one doc
PUT /api/doc {path, data} replace doc
PATCH /api/doc {path, patch} shallow merge, create-on-miss
DELETE /api/doc ?path= delete one doc
GET /api/collection ?path= list docs under collection
POST /api/collection {path, data} addDoc: auto-id under collection
POST /api/batch {ops:[{type:'set'|'update'|'delete', path, data?}]} atomic multi-write

All paths bare canonical (no artifacts/... prefix).

id format

generateId() = crypto.randomUUID() (fallback id_{Date.now()}_{rand10}). Use either. Server POST /api/collection generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.

Object templates

Encounter doc (full, for PUT / batch set):

{
  name: 'Barrowdeep 1 - Antechamber',
  createdAt: new Date().toISOString(),
  participants: [],          // inline, NOT a sub-collection
  round: 0,
  currentTurnParticipantId: null,
  isStarted: false,
  isPaused: false,
  ruleset: '5e',
}

Participant object (item in encounter.participants[]):

{
  id: generateId(),
  name: 'Ankheg',
  type: 'monster',            // 'character' | 'npc' | 'monster'
  originalCharacterId: null,  // set only if type==='character'
  initiative: rollD20() + initMod,  // rolled ONCE at add; stored, not re-derived
  maxHp: 45,
  currentHp: 45,              // start at full
  conditions: [],
  isActive: true,
  status: 'conscious',
  deathSaveSuccesses: 0,      // character/npc only
  deathSaveFailures: 0,       // character/npc only
}

Initiative semantics

  • Default: initiative = rollD20() + initMod (d20 ∈ [1,20], roll fresh per participant).
  • Manual override (e.g. pre-rolled values): set initiative directly, skip the roll.
  • Fixed at add time. NOT re-derived from initMod after — initMod is not stored on participant.

Recipe: build encounters for a campaign

  1. Find campaign id by name:
curl -s "$HOST/api/collection?path=campaigns" \
  | jq -r '.[] | select(.name=="2026 D&D") | .id'
  1. Build encounter docs + participants client-side (object templates above), roll initiative per participant.
  2. Write all encounters in one batch:
curl -s -X POST "$HOST/api/batch" \
  -H 'Content-Type: application/json' \
  -d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
  1. Verify:
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'

Participants go inline in the encounter doc — there is no participants sub-collection. A single batch set per encounter writes the whole roster atomically.

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 CombatisPaused=true, Next Turn disabled
  • While paused: add/remove participants, adjust HP, edit initiative, reorder ties
  • Resume CombatisPaused=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 isActive only, stay in slot
  • Remove = drop from participants[] + sync, advance current if needed

No re-sort after startEncounter.

Storage paths quick reference

Bare canonical (server mode). App code prefixes these with artifacts/{APP_ID}/public/data/ — adapter strips it.

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

REST mapping: campaigns/{cid} doc → GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}. Collection parent (campaigns, campaigns/{cid}/encounters) → GET/POST /api/collection?path=....

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.