02 — Worlds
World CRUD, the world document and its overlays, branches/versions, and world-level operations.
All routes require auth; reads need world:read, writes world:write
(or world:commit when committing — see below). Owner-scoped: a
non-owned world answers 404 (existence is never leaked).
CRUD
| Method | Path | Notes |
|---|---|---|
GET | /worlds | List the caller's worlds ({ id, name, schemaVersion, version, updatedAt }[]) |
POST | /worlds | Create — body { name, schema_version, json } → 201 { id, version } |
GET | /worlds/:id | Detail including json (the canonical world document) |
PATCH | /worlds/:id | Patch name and/or json — exactly one of confirm: true / propose: true |
DELETE | /worlds/:id | Delete the world |
POST | /worlds/:id/duplicate | Deep copy |
POST | /worlds/:id/resize | Grid resize (migration-backed) |
PATCH modes
{ confirm: true, json }— write now. Needsworld:commit; owner only.{ propose: true, json }— file a proposal into the owner/GM review inbox. Needsworld:write; open to GMs.- neither or both →
400 { error: 'commit_required' }. - Optional
base_versiongives optimistic concurrency (409 version_conflictwhen stale).
Translation drafts
Owner or GM only (another member 403 { error: 'forbidden', reason: 'authors_only' }, a stranger or missing world 404). Both routes read the
lines from the stored world, never from the request, and return drafts —
nothing is written to the world. Request bodies are strict.
| Method | Path | Notes |
|---|---|---|
POST | /worlds/:id/translations/draft | { to, treeId?, nodeIds?, overwrite? } → { from, to, drafts: [{treeId, nodeId, text, engine, revised}], failures: [{treeId, nodeId, reason}], remaining } |
POST | /worlds/:id/translations/terms | { to, terms } → { from, to, drafts: [{term, target}], failures: string[] } |
to: language tag (^[a-z]{2,3}(-[a-z0-9]{2,8})*$, lower-cased, max 20)./draft:nodeIds(1–60 ids) requirestreeId;overwriteredoes lines that already have a translation (default off).remainingis the number of lines still to do after the 60-line cap. If the world is already written in the target language, every line comes back infailures./terms:termsis 1–40 strings of 1–120 chars;fromis the world'slanguage(defaulten). A term whose draft is empty, over 120 chars or multi-line goes tofailures.- Errors:
503 { error: 'translation_unavailable', message }(no provider inPLAY_TRANSLATE_PROVIDERS, or the providers failed);429 { error: 'rate_limited', retryAfterSeconds }(+Retry-After; 6/min, burst 3, per world and caller);429 { error: 'daily_limit' }(WORLD_TRANSLATE_DRAFT_MAX_TOKENSper author, world and UTC day). - Cost is the platform's, metered as ledger purpose
translate. The editor workflow, guards and glossary are described in 12 — Play.
I did not find a rule for /worlds/:id/translations in the API-key scope
table (auth/api-key-scopes.ts), so what scope an API key needs here is
unverified.
Document overlays
Large per-world sections live behind their own PATCH endpoints (ADR-0008
write path; runtime state never touches worlds.json):
| Method | Path | Overlay |
|---|---|---|
PATCH | /worlds/:id/cinematic | cinematic config |
PATCH | /worlds/:id/camera-paths | cameraPaths |
PATCH | /worlds/:id/shots | shots |
POST | /worlds/:id/shots/generate | AI shot-list generation |
Branches & versions
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/branches | List forks |
GET | /worlds/:id/diff | Top-level JSON diff of a branch vs its source |
GET | /worlds/:id/source | The branch's source world |
POST | /worlds/:id/accept | REPLACE source with branch (version-guarded, source-owner only) |
POST | /worlds/:id/accept-fields | Selective accept — { keys: string[] } |
World reads & world meshes
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/entities-diff | Entity-level diff (parity with local diffing) |
GET | /worlds/:id/meshes | List world-attached meshes |
POST | /worlds/:id/meshes | Attach a mesh |
GET | /worlds/:id/meshes/:meshId/file | Download the mesh blob |
POST | /worlds/:id/biomes/generate | Procedural biome/terrain generation (owner/GM, CAS) |
GET /worlds/:id/entities-diff
Compare-before-apply for the entities generator: what a regenerate would change in the stored world, without changing it.
GM only (an owner, or a member with role gm): a player or observer gets 403 gm_only, a non-member 403 no_membership, an
unknown world or an invalid stored document 404.
Query (the generator's inputs; the server runs the same entitiesForPreset the editor runs):
| Param | Required | Meaning |
|---|---|---|
preset | yes | jungle, stone-dungeon, medieval-city, arid-desert, wetland, archipelago, volcanic or caverns |
cols, rows | yes | grid size, positive integers |
projection | yes | hex, square or freeform |
seed | no | defaults to the world id |
tileSize | no | positive integer |
hexOrientation | no | pointy-top or flat-top (default: the stored world's) |
largeBuildings | no | 1 grows large buildings in the settlement pass |
walls | no | 1 plans city walls round the settlements |
structures | no | 1 plans town structures: squares, places of worship, wells, roles, places and, with walls, a garrison |
castles | no | 1 grows castles in the country: a keep, a ring and a ward each (schema 0.51.0). Off: the generator's stored castles are reported as removed, because their keeps are generator buildings |
The response is { current, proposed, added, removed, moved }. current, proposed, added and removed each carry one id
list per collection: namedSlots, pois, npcs, spawns, npcHomes, dialogue, quests, regions, hazards, objects,
buildings, lore, and (optional, absent from an older server) walls, squares and castles; moved lists the namedSlots and
spawns whose cell changed. Objects, buildings, walls, squares and castles are merge-aware (a castle's ring is a wall tagged castle:<id>, merged together with the castles: mergeGeneratedCastleSet): proposed is what the world will hold
after Apply (the generator's half replaced, the author's kept), not the raw generator output. The Zod shapes are in
packages/api-schema/src/entities-diff.ts.
GET /worlds/:id returns the whole document; validate and migrate it
with the SDK's /offline tools before re-uploading.