WorldForge Studio

09 — Multiplayer, Versioning & Collaboration

Multiple designers can edit the same world simultaneously — terrain by one, art by another, quests by a third. Every change is versioned, every conflict is resolved via Yjs CRDTs, and the canonical world document is the only source of truth.


0. Where we actually are (refreshed 2026-09-06)

The sections below describe the intended collaboration design. This block records what is actually on main, verified against apps/api/src, apps/web/src and git log (PRs #69–#98). Ground truth for milestones is superpowers/STATUS.md; design rationale for branching is in ADR 0004.

Design elementStatusWhere
Yjs CRDT co-editing realm (§2)✅ SHIPPED (tags spec-11-foundations-complete, spec-11-phase-3-complete)apps/api/src/realm/{wss,connection,doc-store,persistence,auth,feed}.ts — ws server + y-websocket protocol; editors-only realm.
Live presence (§7 activity, cursors)✅ SHIPPED (tag spec-14-live-presence-layer-complete)apps/api/src/presence/* + routes/presence.ts + SSE routes/stream.ts (per-viewer fanout).
Server-filtered fog of war for viewers✅ SHIPPED (tags spec-11-phase-4-w1..w4-complete)apps/api/src/visibility/*, routes/regions.ts (regions-fow), web viewer at /world/[worldId].
Roles (§1)⚠️ SimplifiedMemberships carry gm / player / observer (routes/memberships.ts), not the six-role Owner / Designer / Artist / Writer / GM / Player matrix in §1 / §8.
Versioning + branching (§3)✅ Slices 1–3 (PRs #69 / #70 / #71, 2026-08-23)POST /worlds/:id/duplicate (fork primitive — anyone with read can fork), GET /worlds/:id/branches (list + source backlink), GET /worlds/:branchId/diff (top-level JSON key diff), POST /worlds/:branchId/accept (version-guarded, 409 stale_source). Accept is a REPLACE of the source JSON, not a three-way merge — see ADR 0004. UI: BranchesPanel + BranchAcceptPanel mounted in the editor (PR #96). E2E: e2e/world-branching-accept.spec.ts (PR #85, #98). Optimistic version concurrency on PUT /worlds/:id since tag worlds-rest-api-complete.
True CRDT / three-way merge of branches❌ Not builtNeeds its own spec; the loud "replace" affordance is the current answer.
Lore database (§4)⚠️ Partial via wikipackages/wiki + routes/wiki.ts + /worlds/[id]/wiki (Spec 8, untagged) provide markdown pages; no typed lore entities / cross-references as designed.
Campaign manager (§5)⚠️ Backend CRUD onlyroutes/campaigns.ts (GET/POST/GET :id/PATCH :id) + campaigns table; no campaign UI or session scheduling.
Comments & annotations (§6)❌ Not built—
Activity feed (§7)⚠️ Backend onlyroutes/feed.ts (GET /feed) + realm feed; no dedicated feed UI beyond Explorer's recent-events row.
Offline-first (§9)⚠️ PWA shell onlyService worker + manifest cache the app shell; guest worlds live in IndexedDB. No offline Yjs queue / replay-on-reconnect.

1. Collaboration model

1.1 Roles

RoleCan edit
Ownereverything, including share settings
Designerterrain, regions, biome map
Artistobjects, decorations, theme packs
WriterNPCs, quests, dialogue, lore
GMfog of war, notes, GM-only regions
Playerread-only in player mode

A world has exactly one Owner. Other roles are assigned per-world.

1.2 Permissions

Permissions are enforced at the schema layer, not the UI:

type EditPermission =
  | 'terrain:write'
  | 'objects:write'
  | 'regions:write'
  | 'npcs:write'
  | 'quests:write'
  | 'theme:write'
  | 'meta:write'
  | 'share:write';

A Writer role has npcs:write + quests:write; nothing else. The runtime refuses any mutation outside the role's grants.


2. CRDT collaboration

The world is a Yjs document (Y.Doc). All edits go through Yjs operations; the canonical World.json is the snapshot of the Y.Doc.

World.json (canonical)
   │
   ▼
Y.Doc (live, replicated)
   │
   ├── Y.Map  world.meta
   ├── Y.Array world.terrain
   ├── Y.Array world.objects
   ├── Y.Array world.regions
   ├── Y.Array world.npcs
   ├── Y.Array world.quests
   ├── Y.Array world.cameraPaths
   └── Y.Map  world.gas

2.1 Why Yjs

  • CRDT semantics — no merge conflicts, offline-first.
  • Provider-agnostic — IndexedDB locally, WebSocket for live collab, REST for history.
  • Plays well with our schema — every top-level field is a Y type.

2.2 Persistence

  • Local: y-indexeddb — every change is committed locally.
  • Cloud: a Yjs WebSocket server (y-websocket) hosted on Hetzner via game-asset-platform.
  • Snapshots: periodic snapshots to S3 for backup + history view.

2.3 Awareness

Yjs awareness broadcasts cursor positions, selection rectangles, and "User X is editing the jungle region". Each user sees others' cursors live with name tags.


3. Versioning

Every meaningful state of the world is a version.

interface WorldVersion {
  id: string;                       // ULID
  worldId: string;
  parentId: string | null;
  author: string;
  message: string;
  createdAt: string;
  /** Snapshot of Y.Doc state at this version. */
  snapshot: Uint8Array;
  /** Tags (e.g. 'release', 'milestone'). */
  tags: string[];
}

3.1 Snapshot strategy

  • Snapshot every N operations (default: 500).
  • Snapshot on explicit commit (the user clicks "Save version").
  • Snapshot on publish (when shipping a marketplace pack).

3.2 Time machine

The History panel shows a list of versions; clicking one restores the world to that state. Diff between versions:

  • Terrain: changed / removed tiles.
  • Objects: added / removed / modified.
  • Regions: added / removed / repainted.
  • NPCs / quests: added / removed / modified.
  • Theme: swapped.

The user can branch from any historical version and merge forward — full git-like semantics for the world document.

3.3 Branching

A branch is a forked Y.Doc + history:

main ─────●──────●──────●──────● (v1.0)
              \
               \─ feature/naval-combat ──●──● (v0.9-naval)

Branches can be merged back into main with a structured conflict UI:

  • Terrain conflicts: pick A / B / both / neither.
  • Object conflicts: same.
  • Quest conflicts: merge stage lists.

4. Lore database

The world document has a lore section (Phase 5):

interface LoreEntry {
  id: string;
  title: string;
  body: string;                    // markdown
  tags: string[];
  links: LoreEntry[];
  secrets: boolean;                // hidden in player mode
  owner: string;                   // which faction / NPC owns this knowledge
}

Every NPC, region, quest, and object can link to lore entries. The lore database is the worldbuilding wiki that lives inside the world.


5. Campaign manager

Multiple worlds can be grouped into a campaign:

interface Campaign {
  id: string;
  name: string;
  worlds: string[];                // World ids
  timeline: Array<{
    worldId: string;
    day: number;                   // campaign-relative day
    event: string;
  }>;
  party: Array<{
    name: string;
    character: string;
    player: string;
  }>;
  notes: LoreEntry[];
}

A campaign spans multiple worlds (e.g. "Viking Saga: Norway → England → Vinland"). The campaign manager coordinates which world is "active" and lets the GM hop between them in one session.


6. Comments & annotations

Every tile, object, region, NPC, and quest can have comments:

interface Comment {
  id: string;
  author: string;
  body: string;
  createdAt: string;
  resolved: boolean;
  replies: Comment[];
}

Comments are visible to all collaborators; resolved comments stay in the history but are filtered from the default view. Useful for design discussions and QA notes.


7. Activity feed

A live activity feed shows:

  • "Alice painted a jungle region (32 tiles)."
  • "Bob added an NPC: Captain Arvid."
  • "Carol published version v1.0."
  • "Dave exported to Godot."

Activity is GAS-stored and visible on the marketplace listing.


8. Permissions matrix

ActionOwnerDesignerArtistWriterGMPlayer
Edit terrain✓✓✗✗✗✗
Edit objects✓✓✓✗✗✗
Edit regions✓✓✓✗✗✗
Edit NPCs✓✗✗✓✗✗
Edit quests✓✗✗✓✗✗
Edit theme✓✓✓✗✗✗
Toggle fog of war✓✗✗✗✓✗
Read world✓✓✓✓✓✓
Publish pack✓✗✗✗✗✗
Transfer ownership✓✗✗✗✗✗

9. Offline-first

The editor works fully offline:

  • Y.Doc persists locally to IndexedDB.
  • All tools work without a network.
  • Reconnection merges automatically (Yjs CRDT).

No "you're offline, please reconnect" interruptions. The only network calls are explicit: publishing, fetching packs, syncing with collaborators.


10. Conflict resolution

Yjs handles most conflicts automatically (CRDT). The cases that need human intervention:

  • Two users delete the same NPC — Yjs picks one delete; the other user sees a toast "NPC was deleted by Alice".
  • Two users edit the same NPC's schedule — Yjs merges field-by-field. The last write wins per field; both users see the merged result.
  • Branch merge conflicts — structured UI (§3.3).

The runtime never silently drops a write.


11. Privacy & ownership

  • The owner controls who can access the world (private / unlisted / public).
  • Public worlds can be read but not edited.
  • Published packs are immutable; updates ship as new versions.
  • Deleted worlds are soft-deleted for 30 days, then purged.

12. Performance

  • Awareness — 10 Hz updates, debounced to 5 Hz for cursor positions.
  • Snapshot frequency — tuned per world size (small worlds: every 50 ops; large worlds: every 1000 ops).
  • Diff rendering — large diffs use a virtualised list.
  • Branch merge — runs in a Web Worker; UI stays responsive.