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.
11 KiB
name, description
| name | description |
|---|---|
| ttrpg-encounter-builder | Seeds D&D encounter rosters into the TTRPG Initiative Tracker via its REST API (no UI clicking). Use when the user asks to "set up encounters", "build encounters", "add participants to a campaign", "seed this encounter list", or provides a statblock list (name + HP + init) for a campaign running on a self-hosted tracker instance running in SERVER MODE (bundled Express backend). NOT for Firebase SDK mode — that has no HTTP backend; write path differs entirely. Harness-agnostic: pi, Claude Code, and Codex all run the same script + curl recipes. |
TTRPG Encounter Builder
Build encounter rosters directly via the tracker REST API. No UI clicking, no browser automation. One batch write per session, then verify.
When to use
User provides a campaign name + a list of encounters, each with named participants carrying HP and initiative modifiers (or rolled initiative). Examples:
- "set up these encounters in the 2026 D&D campaign on bee.icantclick.org:8080"
- "build encounter: Barrowdeep 1 - Ankheg HP 45 Init +0"
- "seed these statblocks into my tracker"
Do NOT use for: starting combat, rolling HP mid-fight, editing live combat state, or driving the player display. Those are in-combat ops, not build ops.
Prerequisites
- A running tracker instance in SERVER MODE (
REACT_APP_STORAGE=server, bundled Express backend mounted). Reachable over HTTP.- Firebase SDK mode NOT supported. No
/api/*endpoints exist; data lives in Firestore via the in-browser SDK with different auth + write paths. This skill cannot reach it. If the user is on firebase mode, tell them: switch to server mode, or build encounters via the UI / a firebase-admin script (out of scope here).
- Firebase SDK mode NOT supported. No
- Node.js 18+ (for
fetch+crypto.randomUUID). Helper script uses only builtins — nonpm install. - Campaign must already exist (created via UI or
POST /api/collection). This skill adds encounters to an existing campaign.
Confirm host reachable + server mode before building:
curl -s <HOST>/api/collection?path=campaigns | head -c 200
Returns JSON array of campaign docs = server mode confirmed. HTML (SPA shell) = firebase mode OR backend not mounted. Either way, this skill won't work — tell the user.
Data model (read this before writing)
Three nested entities, all opaque JSON docs in a path-keyed KV store:
Campaign campaigns/{campaignId}
└─ Encounter campaigns/{campaignId}/encounters/{encounterId}
└─ Participant (inline array item on the encounter doc)
Participants are inline. There is NO participants sub-collection. A
participants path returns empty. Each encounter doc carries its full roster
in a participants[] array.
Path normalization (gotcha)
App code uses firebase-prefixed paths (artifacts/{APP_ID}/public/data/campaigns/...).
The REST API uses bare canonical paths (campaigns/...). The adapter
strips the prefix server-side.
- REST / scripts / direct DB → bare:
campaigns/{cid}/encounters/{eid} - If
GET /api/collection?path=artifacts/.../campaignsreturns[], you used the prefixed form. Drop the prefix.
Encounter doc shape
{
name: 'Barrowdeep 1 - Antechamber',
createdAt: new Date().toISOString(),
participants: [], // inline roster
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: '5e',
}
Build encounters with round: 0, isStarted: false. Starting combat is a
separate in-combat op (UI Start button or a later skill).
Participant object shape
{
id: <uuid>, // crypto.randomUUID(), client-generated
name: 'Ankheg',
type: 'monster', // 'monster' | 'npc' | 'character'
originalCharacterId: null, // set only if type === 'character'
initiative: <int>, // 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
}
type field
monster— DM-controlled, dead at 0 HP (auto-inactive). Default.npc— DM-controlled but keeps death saves (revivable). Use for named allies/villains the DM runs.character— player character. RequiresoriginalCharacterIdlinking to a campaign roster entry. For build-from-statblock, usemonsterornpc, notcharacter.
Initiative semantics
- Default:
initiative = rollD20() + initMod(d20 ∈ [1,20], rolled fresh per participant). - Manual override: set
initiativedirectly (pre-rolled values), skip the roll.initModis NOT stored on the participant — only the finalinitiativevalue persists. - Fixed at add time. Never re-derived after.
id format
generateId() = crypto.randomUUID() (fallback id_{Date.now()}_{rand10}).
Generate participant ids client-side so they're stable across batch writes.
Server POST /api/collection auto-generates a UUID for the doc id — fine for
campaigns/encounters, but for inline participants you generate them yourself.
REST endpoints
All paths bare canonical.
| 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, path, data?}]} |
atomic multi-write |
POST /api/batch is the workhorse for seeding. One batch, all encounters,
atomic.
Build flow
1. Parse the user's request
Extract:
- Host — the running tracker URL (e.g.
http://bee.icantclick.org:8080). If omitted, ask. Do not guess. - Campaign name — must match an existing campaign doc's
namefield. - Encounters — list of
{name, participants:[{name, type?, maxHp, initMod?|initiative?}]}.
A statblock line like Ankheg - HP 45 - Init +0 parses to:
{name:"Ankheg", type:"monster", maxHp:45, initMod:0}.
2. Resolve the campaign id
Campaigns are keyed by id, not name. Look up by name:
curl -s "<HOST>/api/collection?path=campaigns" \
| jq -r '.[] | select(.name=="<CAMPAIGN NAME>") | .id'
Exactly one match expected. Zero → campaign doesn't exist (tell user to create it first). Multiple → ambiguous name, ask user which id.
3. Write encounters (two options)
Option A — helper script (recommended)
scripts/build-encounters.js handles id gen, initiative rolling, batch write,
and verification. Reads a spec file or stdin.
Spec format (JSON array):
[
{
"name": "Optional Road/Trail Ankheg",
"participants": [
{ "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 }
]
},
{
"name": "Barrowdeep 2 - Minor Burial Chamber",
"participants": [
{ "name": "Cult Fanatic", "type": "monster", "maxHp": 39, "initMod": 2 },
{ "name": "Cultist 1", "type": "monster", "maxHp": 9, "initMod": 2 },
{ "name": "Cultist 2", "type": "monster", "maxHp": 9, "initMod": 2 }
]
}
]
Run (resolve script path relative to this skill dir):
node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" spec.json
# or stdin:
cat spec.json | node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" -
Script prints each encounter id + participant init rolls, then verifies.
Option B — raw curl (any harness, no node)
Build the batch payload inline. Generate UUIDs and roll initiative yourself.
One POST /api/batch writes everything:
curl -s -X POST "<HOST>/api/batch" \
-H 'Content-Type: application/json' \
-d '{
"ops": [
{
"type": "set",
"path": "campaigns/CID/encounters/EID1",
"data": {
"name": "Optional Road/Trail Ankheg",
"createdAt": "2026-07-07T00:00:00.000Z",
"participants": [
{ "id": "UUID1", "name": "Ankheg", "type": "monster", "originalCharacterId": null, "initiative": 15, "maxHp": 45, "currentHp": 45, "conditions": [], "isActive": true, "status": "conscious", "deathSaveSuccesses": 0, "deathSaveFailures": 0 }
],
"round": 0, "currentTurnParticipantId": null, "isStarted": false, "isPaused": false, "ruleset": "5e"
}
}
]
}'
Response: {"ok":true}. If you get an error, check path is bare (no
artifacts/ prefix) and JSON is valid.
4. Verify
Always verify after write. Don't trust the batch ok alone.
curl -s "<HOST>/api/collection?path=campaigns/CID/encounters" \
| jq '.[] | {name, participants: (.participants|length), started: .isStarted}'
Expect one object per encounter with correct participant count and
started: false.
5. Report back to user
Tell the user:
- Host + campaign name resolved
- Per encounter: name, id, participant count, rolled initiatives
- Verification result (counts matched)
- That combat is NOT started (encounters sit at
round: 0, isStarted: false). User starts combat via the UI Start button when ready.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
GET /api/collection?path=artifacts/.../campaigns returns [] |
used firebase-prefixed path | drop artifacts/{APP_ID}/public/data/ prefix, use bare campaigns |
GET /health or collection returns HTML (SPA shell) |
server in firebase mode, or backend not mounted | confirm REACT_APP_STORAGE=server + backend running on the instance |
Batch returns ok but encounter missing |
wrong campaign id, or path typo | re-resolve campaign id by name; check path segments |
| Participant count mismatch on verify | participant objects malformed / dropped in JSON | validate each has all required fields; use script to avoid hand-JSON errors |
| Initiative looks wrong | forgot initiative is stored value, not re-derived |
roll d20 + initMod once at build, store the int. No initMod field on participant. |
| Duplicate cultist names across encounters | fine — names need only be unique WITHIN an encounter, not across | no fix needed |
Out of scope
- Creating campaigns (use UI or
POST /api/collectionwith a campaign doc) - Starting combat / advancing turns / HP damage / death saves (in-combat ops)
- Player display control (
activeDisplay/status) - Firebase SDK mode (this skill is server-mode REST only)
- Campaign roster characters (
players[]on campaign doc) — those are templates; this skill adds inline combatants, not roster entries
Reference
Full entity model, combat flow, and storage paths: docs/ENCOUNTER_BUILDER.md
in the repo root. REST + DB internals: docs/DEVELOPMENT.md and
server/index.js.