WorldForge Studio

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

MethodPathNotes
GET/worldsList the caller's worlds ({ id, name, schemaVersion, version, updatedAt }[])
POST/worldsCreate — body { name, schema_version, json } → 201 { id, version }
GET/worlds/:idDetail including json (the canonical world document)
PATCH/worlds/:idPatch name and/or json — exactly one of confirm: true / propose: true
DELETE/worlds/:idDelete the world
POST/worlds/:id/duplicateDeep copy
POST/worlds/:id/resizeGrid resize (migration-backed)

PATCH modes

  • { confirm: true, json } — write now. Needs world:commit; owner only.
  • { propose: true, json } — file a proposal into the owner/GM review inbox. Needs world:write; open to GMs.
  • neither or both → 400 { error: 'commit_required' }.
  • Optional base_version gives optimistic concurrency (409 version_conflict when 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.

MethodPathNotes
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) requires treeId; overwrite redoes lines that already have a translation (default off). remaining is 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 in failures.
  • /terms: terms is 1–40 strings of 1–120 chars; from is the world's language (default en). A term whose draft is empty, over 120 chars or multi-line goes to failures.
  • Errors: 503 { error: 'translation_unavailable', message } (no provider in PLAY_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_TOKENS per 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):

MethodPathOverlay
PATCH/worlds/:id/cinematiccinematic config
PATCH/worlds/:id/camera-pathscameraPaths
PATCH/worlds/:id/shotsshots
POST/worlds/:id/shots/generateAI shot-list generation

Branches & versions

MethodPathNotes
GET/worlds/:id/branchesList forks
GET/worlds/:id/diffTop-level JSON diff of a branch vs its source
GET/worlds/:id/sourceThe branch's source world
POST/worlds/:id/acceptREPLACE source with branch (version-guarded, source-owner only)
POST/worlds/:id/accept-fieldsSelective accept — { keys: string[] }

World reads & world meshes

MethodPathNotes
GET/worlds/:id/entities-diffEntity-level diff (parity with local diffing)
GET/worlds/:id/meshesList world-attached meshes
POST/worlds/:id/meshesAttach a mesh
GET/worlds/:id/meshes/:meshId/fileDownload the mesh blob
POST/worlds/:id/biomes/generateProcedural 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):

ParamRequiredMeaning
presetyesjungle, stone-dungeon, medieval-city, arid-desert, wetland, archipelago, volcanic or caverns
cols, rowsyesgrid size, positive integers
projectionyeshex, square or freeform
seednodefaults to the world id
tileSizenopositive integer
hexOrientationnopointy-top or flat-top (default: the stored world's)
largeBuildingsno1 grows large buildings in the settlement pass
wallsno1 plans city walls round the settlements
structuresno1 plans town structures: squares, places of worship, wells, roles, places and, with walls, a garrison
castlesno1 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.