Use Cases Compare Learn Blog Docs Open Studio

How to Add Real-Time Multiplayer to a 3D Web App

Everyone building 3D multiplayer on the web rebuilds it from scratch. There are at least eight competing DIY stacks — Yjs, Croquet/Multisynq, Trystero, Playroom, SpacetimeDB, Firebase RTDB, raw WebRTC data channels, plain WebSockets — and the one purpose-built tool most people remember, Blender's Mixer, is effectively unmaintained. So you pick a transport, wire it to Three.js, and immediately hit the same wall everyone hits: the transport is the easy 20%, and binding it to the 3D layer performantly is the other 80%.

This post is about that 80% — the design decisions and the concrete message shapes that make real-time collaboration on a 3D scene actually work.

The decisions you actually have to make

Authoritative server vs CRDT vs last-write-wins

For an editor where people naturally divide work ("you do lighting, I do layout"), last-write-wins per object is the pragmatic default. True concurrent edits of the same object are rare, and a timestamp resolves them predictably.

Sync the semantic graph, not the Three.js objects

Do not serialize and broadcast THREE.Object3D nodes. Baked geometry and GPU handles do not survive the wire, and you would flood the channel with data no peer can use. Sync your semantic scene model — the same normalized id → { transform, material, geometry, tags, parentId } graph you should already be saving to disk (see save and reload a Three.js scene). Multiplayer and persistence want the exact same format.

What goes on which channel

Not everything deserves the same treatment. Split by durability and frequency:

DataChannelFrequencyPersisted?
Scene deltas (add/update/remove)reliable (WebSocket / RTDB)on changeyes
Transform during a dragreliable, throttled~20 Hzfinal value only
Cursor / camera presenceephemeral20–30 Hzno
Selection highlightephemeralon changeno

The message shape

Model every change as a delta, not a full-scene snapshot. Three ops cover an editor:

type Vec3 = [number, number, number]
type Transform = { position: Vec3; rotation: Vec3; scale: Vec3 }

type SceneDelta = | { op: 'add'; object: SerializedObject; author: string; ts: number } | { op: 'update'; id: string; patch: { transform?: Transform }; author: string; ts: number } | { op: 'remove'; id: string; author: string; ts: number }

Two details in that update op matter. First, author and ts are on every delta — they are how you defeat echo loops and resolve conflicts. Second, notice patch.transform is sent as a whole Transform, replaced wholesale, not a partial { position }. Wholesale replacement sidesteps the deep-merge bug where a nested partial silently drops rotation or scale.

Applying remote deltas without a re-render storm

Reception is where naive implementations die. If a remote "move" triggers a full-scene React re-render, ten collaborators dragging things will melt the frame budget. Reuse the per-object subscription pattern from avoiding the R3F re-render storm: apply the delta to a store keyed by id, and only the one subscribed mesh re-renders.

import { nanoid } from 'nanoid'

const clientId = nanoid(6)

function broadcastLocal(delta: Omit<SceneDelta, 'author'>) { channel.send({ ...delta, author: clientId } as SceneDelta) }

channel.onMessage((delta: SceneDelta) => { if (delta.author === clientId) return // ignore our own echo applyRemote(delta) })

function applyRemote(delta: SceneDelta) { const store = useSceneStore.getState() switch (delta.op) { case 'add': store.addObjectRaw(delta.object, { silent: true }) break case 'update': store.updateObject(delta.id, delta.patch, { silent: true }) break case 'remove': store.removeObject(delta.id, { silent: true }) break } }

The { silent: true } flag is the single most important line: it tells the store mutation not to re-broadcast. Without it, applying a remote delta fires your local change listener, which broadcasts it back out, and you have an infinite echo loop across the room. The author === clientId guard is the second layer of defense for transports that reflect your own messages back.

Throttling transform updates

TransformControls fires on every frame. Broadcasting at 60–120 Hz will saturate the channel and swamp every other peer. Stream at a fixed rate during the drag, then send one exact final value on release:

import throttle from 'lodash.throttle'

// stream at 20 Hz while dragging const streamTransform = throttle((id: string, transform: Transform) => { broadcastLocal({ op: 'update', id, patch: { transform }, ts: Date.now() }) }, 50)

function onDragEnd(id: string, transform: Transform) { streamTransform.cancel() // drop any pending throttled call // send the authoritative final value, unthrottled broadcastLocal({ op: 'update', id, patch: { transform }, ts: Date.now() }) }

The final unthrottled send matters: throttling can drop the last frame of a drag, so without it two clients can end a session one or two centimeters apart. Locally, keep writing the transform straight to the object via ref during the drag and commit to the store on release — the same refs-over-state trick that keeps the dragger itself at 60fps.

Presence and cursors

Presence is ephemeral and belongs on its own channel — losing a frame of someone's cursor is invisible, so never persist it or route it through your durable delta log.

type Presence = {
  clientId: string
  name: string
  color: string
  cursor: Vec3 // ray-plane hit point in world space
  selection: string | null // id of the object they have selected
}

Broadcast presence at 20–30 Hz and, on RTDB or a similar backend, wire an on-disconnect handler so a collaborator's cursor disappears when their tab closes instead of freezing on screen.

Picking a transport

StackModelBest for
YjsCRDTtext, comments, offline-merge logic
Firebase RTDBauthoritative storefast start, presence, on-disconnect
Trystero / WebRTC data channelpeer-to-peerlow latency, no server cost
Croquet / Multisynqsynchronized computationdeterministic shared simulation

There is no wrong answer, only a wrong binding. The transport moves bytes; you still have to translate those bytes into scene-graph mutations that do not re-render the world.

The three pitfalls that will get you

  1. Echo loops. Covered above: guard by author, and mark remote-applied mutations silent so they do not re-broadcast. This is the number-one bug in every hand-rolled 3D collab layer.
  2. Late-join state sync. A client joining at minute 30 must not replay 30 minutes of deltas from t=0. Send them a full snapshot — the exact same SerializedScene document you use to save to disk — then start streaming deltas from that point. Persistence and multiplayer sharing one format pays off precisely here.
  3. Offline. Reliable transports drop. The pragmatic answer for most editors is to re-fetch the authoritative snapshot on reconnect and discard local unsynced deltas, or queue them and replay if your ops are idempotent. Genuine offline merge — two people editing disconnected and reconciling later — is the one case where a CRDT like Yjs earns its complexity. Reach for it only when you truly need it.

How Yugma handles this

Yugma ships real-time multiplayer on the shared semantic scene graph as a default, not a bolt-on: per-object deltas over the same format used for save/reload, per-object store subscriptions so a remote edit re-renders one mesh and not the scene, throttled transforms, live cursors, and object-anchored comments. Because AI edits are just more tool calls against that same graph, a prompt from one collaborator streams to everyone in the room as ordinary deltas.

To be honest about the boundary: for pure logic or text sync, a CRDT library like Yjs is excellent and you should use it. The hard, unglamorous work Yugma has already done is binding that sync layer to the 3D scene performantly — the throttling, the echo guards, the late-join snapshot, the re-render discipline — so you do not rebuild the 80% again.

Frequently asked questions

Should I use a CRDT like Yjs for 3D multiplayer?

Use it for the parts CRDTs are great at — comment threads, text, and offline-merge logic. For the scene graph itself, last-write-wins per object with a timestamp is simpler and usually sufficient, because concurrent edits of the same object are rare in a real editing session. The genuine reason to put the scene graph in a CRDT is hard offline-merge requirements.

How do I stop remote updates from re-rendering my whole scene?

Key your scene state by object id and subscribe per-object at the leaf component, so applying a remote delta updates one entry and re-renders one mesh. Broadcasting full-scene snapshots or subscribing to the whole object map is what causes the re-render storm.

How do new collaborators get the current scene when they join?

Send a one-time full snapshot on join — the same serialized scene document you would save to disk — then stream deltas from that moment forward. Never make a late joiner replay the entire delta history from the start of the session.

See how the Yugma scene graph works →