Three.js From Zero · Article s2-07
Multiplayer Foundations — Presence & Cursors
Multiplayer Foundations — Presence & Cursors
A shared 3D space, two browser tabs, cursors floating in sync. The minimum viable
multiplayer demo — and the foundation for everything else. We'll build it with a technique
you don't need a server for (BroadcastChannel, same-origin), then swap that
primitive for Y.js + WebSockets for actual cross-network play. Same
pattern, different transport.
Try the demo: open this same URL in a second browser tab. Move your mouse over the 3D scene — the other tab sees your cursor float in 3D, and you see theirs. Each tab picks a random name + color on load.
Move your mouse over the scene — the other tab sees your cursor.
The shape of multiplayer state
Every multiplayer app has two kinds of shared state:
| Synced state | Awareness state | |
|---|---|---|
| What | The durable document everyone edits | Ephemeral "who is here and where they're looking" |
| Examples | Figma shapes, Google Doc text, scene graph | Cursors, selections, viewport, typing indicator |
| Persistence | Saved to a server / local disk | Dies when the peer disconnects |
| Conflict handling | CRDT / OT / last-write-wins | Last-write wins is fine (no conflicts) |
Today's article covers awareness. Synced state with CRDTs is S2-08.
CRDTs in one page
A Conflict-Free Replicated Data Type is a data structure where every operation merges with every other operation, in any order, and all peers converge on the same final state. You don't need a coordinating server to resolve conflicts — the math does it for you.
Y.js is the production JS CRDT. It gives you Y.Map, Y.Array,
Y.Text, and a sync protocol. Think "IndexedDB that also syncs to other
browsers via WebSocket or WebRTC".
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://demos.yjs.dev', 'my-room-name', doc,
);
const shared = doc.getMap('state');
shared.observe(() => console.log('state changed', shared.toJSON()));
shared.set('color', 'blue'); // syncs to everyone in the room
Two browser tabs, same room name: any set in one shows up in the other in
~50ms. If both write at the same time, CRDT merges deterministically — no lost writes.
Awareness — the "who is here" protocol
Y.js ships awareness as a separate channel on the same connection. It's
designed for ephemeral state and never writes to the CRDT doc:
const awareness = provider.awareness;
awareness.setLocalState({
name: 'Alice',
color: '#38bdf8',
cursor: { x: 0, y: 1.5, z: 0 }, // 3D cursor position
lookAt: { x: 0, y: 0, z: -1 },
});
awareness.on('change', () => {
const everyone = awareness.getStates(); // Map<clientID, state>
everyone.forEach((state, id) => updateRemoteCursor(id, state));
});
Awareness messages are broadcast (not stored) and the server drops them when a client disconnects. Ideal for cursors — you'd never want yesterday's cursor position in your document.
The single-file demo — BroadcastChannel trick
For this article's demo we skip the server entirely. BroadcastChannel is a
browser API that lets same-origin tabs talk directly via a shared channel. Zero
infrastructure — perfect for local multiplayer demos, and the API shape is nearly
identical to Y.js awareness.
const ch = new BroadcastChannel('threejs-s2-07');
const myId = crypto.randomUUID();
const peers = new Map();
// Broadcast my state
ch.postMessage({
from: myId,
cursor: { x: 0, y: 1, z: 0 },
color: '#38bdf8',
name: 'Alice',
});
// Receive others'
ch.addEventListener('message', (event) => {
peers.set(event.data.from, event.data);
});
The swap to real Y.js is ~10 lines. BroadcastChannel has no server, WebSocket needs one, but the game-loop code that consumes "a list of peer states" is unchanged.
Throttling updates — don't flood the channel
60Hz updates are wasteful. Cursors don't need to match frame rate. Typical pattern:
let lastSent = 0;
const SEND_HZ = 20; // 20Hz = plenty smooth for cursors
function sendIfTime(state) {
const now = performance.now();
if (now - lastSent < 1000 / SEND_HZ) return;
lastSent = now;
ch.postMessage({ from: myId, ...state });
}
For cursors: 20Hz is fine. For position in a game: 30Hz. For critical gameplay (competitive FPS): 60Hz+. Always the smallest rate that doesn't feel jittery.
Interpolation — smoothing remote peers
Even at 20Hz send rate, if you render remote peers at their raw incoming positions they'll visibly teleport every 50ms. Interpolate toward the latest known position:
// Every frame, for each remote peer:
remote.mesh.position.lerp(remote.targetPos, 0.25); // 25% of the way per frame
This introduces a small visual latency (1-2 frames behind real) but eliminates visible snapping. It's the same trick multiplayer game engines have used for 25 years.
Sending 3D cursor position
For a cursor-in-3D experience, raycast from the mouse into the scene each frame and send the hit point as your cursor position:
canvas.addEventListener('pointermove', (e) => {
const rect = canvas.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects([groundPlane]);
if (hits.length) {
myState.cursor = { x: hits[0].point.x, y: hits[0].point.y, z: hits[0].point.z };
sendIfTime(myState);
}
});
Peer lifecycle — joins and drops
Two events you handle explicitly:
// Announce myself when I join
ch.postMessage({ type: 'hello', from: myId });
// Reply with "I'm here too" when I receive someone else's hello
if (msg.type === 'hello') ch.postMessage({ type: 'ack', from: myId });
// Drop peers that haven't pinged in a while
setInterval(() => {
const now = performance.now();
for (const [id, p] of peers) {
if (now - p.lastSeen > 3000) { peers.delete(id); removeRemoteMesh(id); }
}
}, 500);
3-second timeout catches dead tabs (closed without notifying) cleanly. Real Y.js awareness handles this automatically — it's the provider's job.
Picking colors + names
Each peer should be visually distinct on sight. Generate on join:
function randomIdentity() {
const hue = Math.random();
const color = `hsl(${hue * 360}, 70%, 60%)`;
const animals = ['fox', 'otter', 'panda', 'lynx', 'hare', 'swift'];
const name = animals[Math.floor(Math.random() * animals.length)];
return { color, name };
}
For real apps, persist this to localStorage so a reload keeps the same
identity.
Upgrading to Y.js + WebSocket
Replace the BroadcastChannel with a real provider for cross-device play. The public Y.js demo server works for prototypes:
// npm i yjs y-websocket
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://demos.yjs.dev', 'my-room-name', doc,
);
provider.awareness.setLocalState(myState);
provider.awareness.on('change', () => {
provider.awareness.getStates().forEach(updateRemoteCursor);
});
For production: run your own y-websocket-server (Node, tiny), or use
PartyKit (WebSocket-first serverless), or
Liveblocks (presence + storage as a service), or
Hathora (session-based rooms).
Provider alternatives compared
| Provider | Transport | When it wins |
|---|---|---|
y-websocket | Node WebSocket server | Full control, self-host |
y-webrtc | Peer-to-peer with signaling | No server costs; small rooms (< 10) |
| PartyKit | Cloudflare-native WebSocket | Edge-fast, pay per request |
| Liveblocks | WebSocket + storage | Presence UI primitives out of the box |
| Hathora | Session rooms + state | Competitive gaming, matchmaking |
Common first-time pitfalls
- Remote cursors snap/teleport. No interpolation. Lerp targets toward incoming positions.
- My own cursor shows up as a peer. Compare
event.data.fromto your ownmyIdand skip. - Peers never drop. No TTL. Add a lastSeen field + a timer that sweeps.
- Flooding the channel. Sending on every mousemove at 1000Hz. Throttle to 20-30Hz.
- State races. Two writes to the same key = one wins. That's fine for cursors. For synced state (S2-08), use a CRDT.
- BroadcastChannel doesn't work cross-origin. Right — it's same-origin-only. For real multiplayer, swap to y-websocket.
Exercises
- Add chat: a text input that broadcasts a chat message. Display speech bubbles above each peer's 3D cursor for 4 seconds.
- Display name above cursor: a billboard plane that faces the camera with the peer's name.
- Persistent identity: save name + color to
localStorage, reuse across reloads.
What's next
Article S2-08 — Authoritative Multiplayer. Prediction, reconciliation, interest management, snapshot interpolation. The step from "everyone sees everyone's cursors" to "everyone agrees on the state of the world".