Three.js From Zero · Article s9-07
S9-07 Network Sync
Network Sync & Clocks
Multiplayer 3D needs time. Player A's clock doesn't match Player B's. Server at 60fps, clients at 120fps. Lag compensation, interpolation, clock sync. Techniques from the industry.
1. The sync challenge
- Clock skew: Player A's "now" ≠ Player B's.
- Variable latency: 30ms-300ms, jittering.
- Packet loss: 0-5% typical, bursty.
- Out-of-order delivery: latest-write-wins is wrong.
2. NTP-style clock offset
// Client sends
const t0 = performance.now();
ws.send({ type: 'ping', t0 });
// Server echoes immediately
ws.on('ping', msg => ws.send({ type: 'pong', t0: msg.t0, tServer: Date.now() }));
// Client on pong
ws.on('pong', msg => {
const t1 = performance.now();
const rtt = t1 - msg.t0;
const offset = msg.tServer - (msg.t0 + rtt / 2);
// Now: client's concept of server time = performance.now() + offset
});
Run periodically (every 5s). Median over last 10 samples to smooth jitter.
3. Entity interpolation
Server broadcasts positions at 20Hz. Client renders at 60Hz. Interpolate between the two newest snapshots at render time.
const snapshots = []; // [{ t: serverTime, pos: Vector3 }]
function render() {
// Render 100ms in the past so we have future snapshots
const renderTime = serverTime() - 100;
const a = snapshots.findLast(s => s.t <= renderTime);
const b = snapshots.find(s => s.t > renderTime);
if (a && b) {
const alpha = (renderTime - a.t) / (b.t - a.t);
mesh.position.lerpVectors(a.pos, b.pos, alpha);
}
}
4. Client-side prediction (you)
Your inputs feel instant — you predict locally, server confirms.
// Local input → immediate move
player.position.x += input.dx;
sendToServer({ seq: ++inputSeq, input });
// Server echoes authoritative position
ws.on('ack', msg => {
// Replay inputs after msg.seq on top of msg.serverPos
player.position.copy(msg.serverPos);
for (const i of pendingInputs.filter(p => p.seq > msg.seq)) {
applyInput(player, i);
}
});
5. Lag compensation
When you shoot, server rewinds the target's position to where YOU saw them (your RTT ago). Hit detection happens in "your" view of the world.
Counter-Strike, Valorant. Controversial but fair-feeling for the shooter.
6. Delivery guarantees
| Protocol | Use for |
|---|---|
| WebSocket (TCP) | Chat, state changes, events |
| WebRTC DataChannel (UDP-ish) | Position updates, anything real-time |
| HTTP polling | Never |
7. Binary format
JSON is 3-5× larger than needed. Use typed arrays:
// Position update: 4 bytes id + 12 bytes pos + 4 bytes timestamp = 20 bytes
const buf = new ArrayBuffer(20);
const view = new DataView(buf);
view.setUint32(0, entityId);
view.setFloat32(4, pos.x);
view.setFloat32(8, pos.y);
view.setFloat32(12, pos.z);
view.setUint32(16, timestamp);
ws.send(buf);
8. Delta compression
Send deltas from last acknowledged state. Server keeps per-client "last acked" state. Huge bandwidth savings when state doesn't change much.
9. Platform choices
- Y.js + y-websocket: CRDT, eventual consistency, collaborative editing.
- Colyseus: Node game server with room abstraction.
- PartyKit: Cloudflare Workers multiplayer.
- Supabase Realtime: Postgres + WebSocket.
- Geckos.io: UDP-like via WebRTC DataChannel.
10. Takeaways
- Sync client clocks via NTP-like offset.
- Render 100ms behind server time for interpolation.
- Predict local inputs, reconcile on server ack.
- Lag-compensate authoritative hits.
- WebRTC DataChannel for real-time position.
- Binary wire format. Delta compression when possible.
Network article — no single-page demo. See S2-08 for a split-screen client/server prediction + reconciliation demo.