Three.js From Zero · Article s2-07

Multiplayer Foundations — Presence & Cursors

← threejs-from-zeroS2 · Article 07 Season 2
Article S2-07 · Three.js From Zero

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.
connecting…
Open this URL in a 2nd tab to see multiplayer.
Move your mouse over the scene — the other tab sees your cursor.
no peers

The shape of multiplayer state

Every multiplayer app has two kinds of shared state:

Synced stateAwareness state
WhatThe durable document everyone editsEphemeral "who is here and where they're looking"
ExamplesFigma shapes, Google Doc text, scene graphCursors, selections, viewport, typing indicator
PersistenceSaved to a server / local diskDies when the peer disconnects
Conflict handlingCRDT / OT / last-write-winsLast-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

ProviderTransportWhen it wins
y-websocketNode WebSocket serverFull control, self-host
y-webrtcPeer-to-peer with signalingNo server costs; small rooms (< 10)
PartyKitCloudflare-native WebSocketEdge-fast, pay per request
LiveblocksWebSocket + storagePresence UI primitives out of the box
HathoraSession rooms + stateCompetitive 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.from to your own myId and 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

  1. Add chat: a text input that broadcasts a chat message. Display speech bubbles above each peer's 3D cursor for 4 seconds.
  2. Display name above cursor: a billboard plane that faces the camera with the peer's name.
  3. 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".