WorldForge Studio

12 — Play (AI-DM playthroughs and characters)

Characters, and playthroughs: a table that plays through a world's story as an append-only event log (ADR-0010). Identity and permissions are ADR-0011; what a response may contain is ADR-0014.

Everything here is behind the PLAY_ENABLED flag (default off). With the flag off every route under /play answers 404, even without credentials. Scopes: play:read (GET) and play:write (everything else); a session JWT carries *. An API key acts as its owner and is audited as agent.

Not-yours and does-not-exist look the same (404), so ids cannot be probed. Errors are { "error": "<code>", "message": "...", "details": [...] }.

Rules systems

MethodPathNotes
GET/play/systemsSystems that have characters, with their licence and creation options (archetypes, backgrounds)

Companions

A companion is a character with kind: "companion" and a complete persona. Drafts are generated, reviewed by the host, then saved:

MethodPathNotes
POST/play/companions/draftBody: worldId, optional archetype, background, systemId, seed, hints (up to 5 short lines of world lore, treated as data), useModel. Saves nothing. Returns { draft }: name, archetype, background, sheet, derived, persona, source and, if a model was asked for but not used, fallback
  • source: "rules" is deterministic from seed (same seed, same companion) with no model and no cost. useModel: true uses your own key (metered as companion); its output must pass the persona schema, completeness and a screen for protected names, or you get the rules draft with fallback: "rejected" ("no_key" and "llm_error" likewise). A model request never fails the call.
  • Save a reviewed draft with POST /play/characters and kind: "companion". A companion needs summary, voice, tactics and at least one motivation (422 invalid_persona lists what is missing), and keeps them through PATCH.
  • Persona fields: summary, voice, traits, motivations, flaws, bonds, tactics { style, notes }, secret. The secret is the companion's own private knowledge and is never shown to other participants.
  • A player cannot seat a companion (422 not_a_player_character); the game seats companions (Phase 5).

Characters

Private to their owner. A character is built and validated by a rules system; derived numbers (max HP, modifiers) are computed on every read and never stored.

MethodPathNotes
POST/play/charactersCreate. Body: worldId, name, and either archetype + background (quick start, optional level) or a full sheet; optional systemId, persona. Needs access to the world. 422 invalid_sheet lists what is wrong
GET/play/characters?worldId=Your characters
GET/play/characters/:idOne character, with derived
PATCH/play/characters/:idname, sheet, persona; expectedVersion makes it a compare-and-swap (409 conflict)
DELETE/play/characters/:idDelete (204)

Portraits

One picture per character. The upload is untrusted: it is checked by its magic bytes (JPEG, PNG or WebP; the filename and Content-Type are ignored), bounded, rotated by its EXIF orientation and re-encoded to square WebP, which also strips EXIF and anything appended to the file. Two sizes are stored: full (512 px) and thumb (128 px). A character in GET /play/characters/:id carries portraitSha (the picture's hash, or null when it has none).

MethodPathNotes
PUT/play/characters/:id/portraitSet or replace. Owner only. Multipart form data, one field named file. Answers { portrait: { sha256 } }. Audited as character:portrait
GET/play/characters/:id/portrait?size=full|thumbThe picture as image/webp (non-JSON; use a raw fetch). size=thumb gives the thumbnail, anything else the full picture. Sends ETag ("<sha256>-<size>"), Cache-Control: private, no-cache and X-Content-Type-Options: nosniff; a matching If-None-Match gets 304
DELETE/play/characters/:id/portraitRemove. Owner only; 204. Audited as character:portrait-clear
  • Who may read: the character's owner, anyone seated at a table the character sits at, and the host of such a table. Anyone else gets the same 404 as for a character that does not exist, and so does a character with no picture.
  • Upload errors: 413 too_large (over 5 MB; refused from Content-Length before the body is read, with 64 KB of slack for the form framing), 400 invalid_image (not multipart with a file field, empty, corrupt, or over 24 million decoded pixels), 415 unsupported_type (not JPEG, PNG or WebP), 404 (not your character).

Playthroughs

The host is the playthrough's owner. Only the host starts, pauses, resumes, ends it and seats participants (until the multiplayer phase).

MethodPathNotes
POST/play/playthroughsCreate a draft. Body: worldId, optional campaignId (must belong to the world), systemId, settings (tone, difficulty, beatPolicy, allowCanonProposals, hardLines). Pins the rules system, version and policy fingerprint
GET/play/playthroughsThose you host or sit at
GET/play/playthroughs/:idSummary, me, isHost, participants, and state: your view, a replay of only the events you may see
POST/play/playthroughs/:id/startDraft to active; seats the AI narrator
POST/play/playthroughs/:id/pauseBody { reason? }
POST/play/playthroughs/:id/resume
POST/play/playthroughs/:id/endBody { outcome?: "completed" | "abandoned" }; a never-started draft is ended without a log entry
POST/play/playthroughs/:id/joinBody { characterId }: seat yourself with one of your characters (409 already_seated, character_in_use; 422 wrong_system)
POST/play/playthroughs/:id/leave204; you may rejoin
POST/play/playthroughs/:id/actionsAct as your own participant. Body { type: "declare", text, parsed? } (what your character does) or { type: "say", channel: "party" | "ooc", text }; optional expectedSeqHead (compare-and-swap, 409 conflict). 403 not_seated without a seat; 409 wrong_state unless active (out-of-character talk is also fine while paused). The actor is never taken from the body (unknown fields are 400). Returns { event, seqHead }
GET/play/playthroughs/:id/partyThe party's progress as you know it: per character level, XP and progress to the next level, HP, conditions, inventory and gold; plus the quest log and renown
GET/play/playthroughs/:id/recap?sinceSeq="Previously...": a deterministic recap (no model) of what you were shown since sinceSeq (default: since the last recap you were shown): where you are, what was said, quests, XP, levels, loot, checks, who is hurt. { recap: { fromSeq, toSeq, empty, lines, text } }, at most 14 lines
GET/play/playthroughs/:id/export?format=json|markdownDownload your transcript (Content-Disposition: attachment, no-store). It is exactly the events you may see, so it never contains what the narrator kept to itself; a host with no seat gets the spectator view. JSON includes the playthrough, participants and events (view: participant|spectator)
DELETE/play/playthroughs/:idHost only. Body { "confirm": "<the playthrough id>" } or 400 confirm_required. Deletes the whole transcript, snapshots, seats, notes and the LLM-call rows tied to it (204); your characters stay. Open streams are closed. An audit row records the deletion with counts only
GET/play/playthroughs/:id/streamServer-Sent Events, below
GET/play/playthroughs/:id/events?afterSeq=&limit=Your view of the log, in order (limit 1-500, default 200), with seqHead

A host with no seat is a spectator: public and party events only, never the narrator's secrets.

Wrong-state moves (pausing a paused game, joining an ended one) are 409 wrong_state.

Live stream (SSE)

GET /play/playthroughs/:id/stream sends one play.event per log event you may see, in seq order, with the seq as the SSE id. It starts with the backlog, then stays open. To resume after a drop, reconnect with Last-Event-ID (the browser does this by itself) or ?afterSeq=N: you get everything after N, once. A value that is not a number means "from the start".

  • Every connection reads its own filtered view; nothing is pushed to a connection unfiltered. Appends only wake connections up.
  • Access is re-checked on every delivery. If your seat is gone the stream sends closed and ends.
  • keepalive frames every 15 s (PLAY_STREAM_KEEPALIVE_MS overrides it; each one also re-reads the log, which is the floor under a missed wake-up). The wake-up registry is in-process, like the world stream: a second API process relies on that floor.

Authored translations and a glossary (ADR-0015 slice 4)

Human work beats machine work, and it is free at runtime. Two optional, additive fields in the world document:

// a dialogue node (gameplay.dialogue[].nodes[]): its text in other languages, keyed by language tag
{ "id": "n1", "speaker": "Odo", "text": "Evening. Rain has been at the roof all day.",
  "translations": { "da": "...", "es": "..." } }
// the world (world.glossary): a concept in each language it is known in; a pair is used when both languages are present
{ "world": { "glossary": [ { "terms": { "en": "saving throw", "da": "redningskast", "es": "tirada de salvación" } } ] } }
  • A line spoken from a scripted node carries payload.authored: { tree, node }. For a reader whose language the node has (exact tag, else the same primary language), the author's text is shown as written, with translation.engine: "authored", and no engine is called. A node without that language falls back to the machine, line by line.
  • The glossary is handed to the engines for the pair being translated, ahead of the built-in rules vocabulary and the NPC names (which stay as written); only terms that occur in the text are sent. The guards then refuse an answer that does not use them.
  • A translation is cached per (event, language). Edited a translation? Delete that playthrough's event_translations rows (a projection: they are rebuilt on demand).
  • The editor: ⌘K → "Edit translations…" (TranslationsDialog). Pick a target language, then Generate with AI for a tree or for every line still missing, or "Redo with AI" / "Draft with AI" for one line, and adjust the text in place. The glossary tab adds terms (rows with fewer than two languages stay in the dialog), can add the world's names as rows, and can suggest the target term for each row that has a source term. Drafts are applied as one undoable edit, marked "AI draft" until the author edits the line (the mark lives in the dialog only), and the author's own translations are never replaced unless they press "Redo".
  • Generate with AI (POST /worlds/:id/translations/draft { to, treeId?, nodeIds?, overwrite? } and .../terms { to, terms }; owner or GM). It reads the lines from the stored world, never from the request. Each batch of 10 goes with the speaker, the three lines before, the world's name and description, the glossary and the NPC names; a second pass has another provider proofread each draft as a native editor and return it unchanged when it is good. Every answer from either pass is checked by the mechanical guards (numbers, glossary, names, length); a line that cannot pass comes back in failures with the reason and is never inserted. Nothing is written to the world by the route. Cost: the platform's chain (PLAY_TRANSLATE_PROVIDERS), metered as translate against the author and the world, capped at WORLD_TRANSLATE_DRAFT_MAX_TOKENS per author, world and UTC day (default 800000; 0 is off), and rate-limited to 6 a minute (burst 3). Up to 60 lines per request; the editor repeats until nothing is left.
  • Not covered: choice texts, quest titles and POI names have no per-language field yet (the Narrator voices those, and the glossary pins their names). Live check: WF_LIVE_LLM=1 vitest run src/play/translation/draft-live.test.ts (writes a report for a native reader).

Several players (ADR-0011 §5)

  • POST /play/playthroughs/:id/invites { expiresInHours?: 1-720 (72), maxUses?: 1-20 (1) } (host) answers 201 { invite, token }. The token is shown once; only its hash is stored, and GET .../invites lists metadata without it. GET .../invites answers { invites }, newest first, at most 100. At most 20 open invitations per table (409 limit); an ended playthrough takes none (409 wrong_state). DELETE /play/playthroughs/:id/invites/:inviteId revokes and answers 204; an unknown or already revoked invitation is 404. All three are host only: a seated non-host gets 403 forbidden, a stranger 404.
  • POST /play/invites/redeem { token, characterId | newCharacter: { name, archetype, background, level? }, readingLanguage? } takes a seat and answers 201 { participant, playthroughId }. Any bad token (unknown, expired, revoked, used up, malformed) is 404 invalid_invite. already_seated and table_full are 409; neither spends a use. Giving both or neither of characterId and newCharacter is 422 invalid. newCharacter is how a guest with no role on the world gets a character. The web client's page is /play/join#<token> (a fragment, so the token never reaches a server log).
  • DELETE /play/playthroughs/:id/participants/:participantId (host) removes a human (participant.left, reason "removed by the host"). Answers 204. A seat that is not a human is 422 not_a_player, your own seat is 422 use_leave (use POST .../leave), and an unknown or already departed participant is 404.
  • A guest sees only their own view of the log (ADR-0014) and cannot start, pause, end, delete, invite, remove or read telemetry (403).
  • Limits: PLAY_MAX_HUMANS (default 6, max 12). Actions and chat: a token bucket per seat, PLAY_ACTION_BURST (10) refilled at PLAY_ACTIONS_PER_MINUTE (20); past it 429 { error: "rate_limited", retryAfterSeconds } with Retry-After, and the refused action is not recorded. Redeeming is limited to 10 a minute per account (burst 5). 0 per minute turns the action limit off. The buckets are in-process, so a second API process only loosens them.

Reading the table in another language (ADR-0015)

The log is in one language, the story language (settings.language, default en). A seat can read it in another:

  • PUT /play/playthroughs/:id/reading-language { "language": "da" | null } sets the caller's seat and answers { participant } (409 not_seated when the caller has no seat; also accepted as readingLanguage on join). Any BCP 47 tag; null means the story language. The body is strict and language is required (it may be null). A host with no seat passes ?lang=da on GET .../events and .../stream instead.
  • What is translated: the Narrator's voice (narration, npc.said) from the story language, and what players say or do (party.said, ooc.said, action.declared) from the language the author wrote in. Rolls and mechanics are not.
  • What a player writes is stored as typed and tagged by the server with payload.language (the language their seat reads in; omitted when it is the story language). A request cannot set it (the body is strict). The DM reads the text as typed; only other readers get a translation, and a line already in a reader's language is left alone. A player's writing language is assumed to equal their reading language.
  • Nobody waits. GET .../events and the stream deliver every event at once in the story language. A translatable event carries translation: { language, state: "pending" }, { language, state: "ready", text, engine } or { language, state: "failed" }. The original is always in payload, so "show original" needs no request.
  • The stream follows a pending event with play.translation frames { seq, language, state: "ready" | "failed", text?, engine? } keyed by the event's seq.
  • One translation per (event, language), cached in event_translations and shared by every seat reading that language. It is a projection: dropping the table loses nothing. A reader only ever gets translations of events they may see.
  • A failure is shown as failed and not retried for two minutes. A failed text is never cached as an answer.

Operator settings (translation is off unless one of the first two is set; the platform pays, metered as ledger purpose translate and counted toward the playthrough's spend ceiling):

EnvMeaning
PLAY_TRANSLATE_PROVIDERSOrdered ai-gateway chain, provider[:model] comma-separated. Providers: minimax (MiniMax-M3), anthropic (claude-haiku-5-5), glm (glm-5.3-flash), deepseek (deepseek-flash). A provider with no key (MINIMAX_API_KEY, ANTHROPIC_API_KEY, GLM_API_KEY, DEEPSEEK_API_KEY) is skipped.
PLAY_TRANSLATE_OLLAMA_URL / _MODELOptional self-hosted last resort (default model gemma4:e4b). Empty = none.

Every answer passes the mechanical guards (numbers kept, glossary and names exact, sane length) before it is shown; a text no engine can translate safely is returned as failed. The guards cannot see a wrong or invented word, so rule text is not safe to rely on a machine for (audit: docs/audits/2026-10-15-translation-bakeoff.md).

The AI dungeon master

With PLAY_DM_ENABLED=true (off by default; it spends the host's own LLM key, metered on the ledger as dm), a declare action or a say on the party channel wakes the DM after the action has been committed. say on ooc never does. The reply arrives on the live stream like any other event; the action's own response never waits for the model.

  • One beat runs at a time per playthrough. Wake-ups during a beat coalesce into one follow-up beat.
  • A beat is written against the log as it was read. If a player acted while the model was thinking, the beat is discarded unwritten and run again (up to 3 attempts); a half-applied beat is never stored.
  • A model failure, a malformed tool call, or any cap (rounds, corrections, output, wall time) ends the beat with a stored fallback line in the table's language, plus an error.dm event that only the DM audience can see.
  • The host must have an LLM key configured. Without one the DM stays silent and the server logs why.
  • The DM is in-process: run one API instance per playthrough until the distributed lock of Phase 8 exists.

Whose voice, and which provider

The table's owner speaks first: if they configured a key of their own, the DM uses it and their money pays. Only a table whose owner never configured one falls back to the platform's chain — an ordered list of providers, tried in turn, the first that answers winning:

  • PLAY_DM_MODEL_CHAIN (default minimax:MiniMax-M3,glm:glm-5.3-flash,deepseek:deepseek-flash,anthropic:claude-haiku-5-5) is that list, provider:model per entry, in fallback order. A provider with no credential is skipped, so with nothing configured the chain is empty and the DM stays silent as it always did.
  • A route's credential is, in order, a forge-gateway tenant token (FORGE_GATEWAY_TOKEN_MINIMAX, _GLM, _DEEPSEEK, _ANTHROPIC — the provider's key stays in the federation's vault) or else the provider's own key in the environment (MINIMAX_API_KEY, GLM_API_KEY, DEEPSEEK_API_KEY, ANTHROPIC_API_KEY). A gateway token resolves to one tenant, and a tenant has one upstream, so the plain FORGE_GATEWAY_TOKEN (the fleet's single-token convention) applies to the first route only.
  • Every route speaks the wire its provider speaks — the Anthropic-compatible endpoints the catalog already points at — so a gateway route goes through the gateway's Anthropic passthrough (/v1/proxy/anthropic/*).
  • The chain moves on when a route fails (bad key, wrong model name, rate limit, vendor outage). It does not move on once a route has spoken, and it does not move on past a bad request or a budget cap: those stop the beat, because every route would fail the same way and a cap must not be routed around.

Cost, speed and the spending ceiling

Every model call the Narrator makes is a row in llm_calls with the playthrough_id that caused it, so what a table has cost is read from the same ledger the host is billed by.

  • GET /play/playthroughs/:id/telemetry (host only): calls, failed calls, tokens, cost, unpriced calls, whole-call latency p50/p95, time to first token p50/p95, the ceilings and capReached (null, "cost" or "tokens"). The body is { dmEnabled, spend, caps, capReached }. Cost is a floor while unpricedCalls is non-zero (a model with no entry in LLM_PRICING_JSON is unpriced, never free).
  • Ceilings per playthrough: PLAY_DM_MAX_COST_MICROS (default 5,000,000 = $5) and PLAY_DM_MAX_TOKENS (default 3,000,000). 0 turns one off. Tokens are the ceiling that always works, because cost needs a price table.
  • Checked before each beat, at >=. A beat already running can overshoot by its own cost, which its caps bound. At the ceiling the Narrator does not run: the table gets one notice in its own language, the DM audience gets error.dm spend_cap, and further actions write nothing more. Raise the ceiling and the next action wakes the Narrator again.

The price table

LLM_PRICING_JSON is {"<model>": {"input": <usd per million>, "output": <usd per million>}}, and it is generated, never typed by hand:

pnpm pricing:generate                                   # the JSON on stdout
forge env set world-map-editor LLM_PRICING_JSON "$(pnpm --silent pricing:generate)"

--silent is not cosmetic: pnpm prints its > world-map-editor@… pricing:generate banner on stdout, so without it the captured value is the banner followed by the JSON and the deployment cannot parse it.

It is built from the fleet's own price data (@blueforge-studio/ai-pricing), with two rules: a default fallback rate is never a model's price, and a zero rate is dropped — a provider on a flat plan has no per-token price, and a budget that reads a missing price as free lets an unpriced model run unmetered. The models left out are listed on stderr, and while one is left out its calls cost NULL rather than $0.

Until that variable is set, the cost ceiling never engages (the ledger's cost is NULL, which sums to 0) and only PLAY_DM_MAX_TOKENS holds the line. MiniMax-M3 is published at 0/0 and is therefore never priced, so a table whose beats all ran on MiniMax is governed by the token ceiling alone.

Writing scenarios for the DM

  • Scripted conversations (gameplay.dialogue trees with an npcId): the DM is told which people have one and plays it with run_dialogue, then follows the players' choices with the choice ids. The open choices are kept in a DM-only flag (dialogue.open), because tool replies are not in the log. A node can hand out its own quest with offersQuest / acceptsQuest (ids into world.quests); the quest is offered or accepted exactly once, whatever the model does.
  • Language (ADR-0015): world.world.language (a tag like en, da, pt-BR; absent means English) says what the world's own text is written in. The table's story language (settings.language: en, da or es) is what the DM tells the story in. Same language: authored lines are shown as written. Different (an English-authored world at a Danish table, or a German-authored world at an English one): the lines go to the model as source to voice in the story language and none is shown raw. Reading the story in a language other than the story language (per-player translation) is designed in ADR-0015 and not built yet.
  • Secrets: a place tagged hidden or secret is not on the DM's list of places and its description is never offered as ambient scene lore; it appears only if the DM calls recall on purpose, labelled as a secret. Nothing stops a model from inventing its own hatch or trapdoor (a stock trope, and it can contradict yours), so write the hidden thing so that an improvised one does not break it.
  • Names: the DM is told to keep place and person names as listed. A real model translated "The Crooked Mug" anyway ("Den Krumme Kande", "Jarra Tuercida"), differently in different sessions, so expect translated names at non-English tables.

Running it against a real model

apps/api/src/play/eval/live.test.ts plays The Crooked Mug with a real model through the same runDmBeat the server uses. It is opt-in and never part of CI:

WF_LIVE_LLM=1 WF_LIVE_REPORT=/path/report.json [WF_LIVE_LANG=da|es] [WF_LIVE_TURNS=4] \
  pnpm --filter api exec vitest run src/play/eval/live.test.ts

The key comes from the environment (MINIMAX_ANTHROPIC_API_KEY, else MINIMAX_API_KEY) and is used only inside the provider; it is never logged or written to the report. WF_LIVE_BASE_URL / WF_LIVE_MODEL point it elsewhere (for example a forge-gateway tenant, which needs its own gw_ token). It asserts invariants only (honest log, replay equals live, nothing forged, nothing private shown, the model really answered). The report carries per-beat outcome, tools, wall time, tokens, latency and the narration, for a human to judge.

The scene backdrop

GET /play/playthroughs/:id/scene (the host, or any seated member): { scene: null } before the DM has set a scene, otherwise { scene: { placeId, name, tags, backdrop } } where backdrop is { type: 'interior', building, seed } (tavern, wine_cellar, tower, castle, crypt, house) or { type: 'outdoor' }, plus exits ({ id, name, locked?, secret? }[], omitted when empty) for an atlas place: the ways out the party can take and, to the host only, the secret ones it has not found. It is derived from the caller's own view of the log, and only the kind of place and a stable seed leave the server, never the place's notes or anything hidden in it. The web client draws the interior itself from the seed with the pure interior projection, so a playthrough always sees the same tavern and nothing is stored. A place's category decides the building (scene-projection.ts); a place tagged indoor with an unknown category is a house; anything else is outdoors.

The first scenario: The Crooked Mug

apps/api/src/play/scenarios/crooked-mug.ts is an authored world in the real document shape: a harbour tavern, a cellar with a hidden trapdoor, a barkeep with an agenda, a ferryman, a guild, a quest, rumours (one true, one false) and a dialogue tree with a renown-gated branch. crookedMugWorld() returns it validated by the same safeParseWorld the create route runs. To play it, POST it to /worlds and create a playthrough on the new world. The scenario test plays it from the tavern door to a finished quest against a real database and requires that nothing the scripted DM does is rejected.

How the DM reads a world (world-adapter.ts): places are world.pois, conversations gameplay.dialogue, people npcs, quests quests, lore lore.entries, factions and rumours (their refs become the links retrieval follows). A place tagged hidden or secret is not shown to the DM as visible until the party reveals it. Item ids come from the pinned rules system's catalogue, not the world. An NPC whose home is the current scene can be addressed before they have ever spoken.

Progress (XP, gold, items, quests, renown)

There is no HTTP write for progress, by design: only the game awards it. XP, levels, items and gold, the quest log and renown are events in the log (so they are per playthrough and replay exactly), applied through apps/api/src/play/engine.ts, which the DM loop uses as its tools. The rules clamp every XP award (a scene cannot hand out a whole level) and compute the new maximum HP when a character levels; you cannot spend gold you do not have; items must exist in the system's catalogue; renown moves in steps of at most 10 and stays within -100..100. Read it with GET .../party.