Skip to content

Add multiplayer to a Three.js game

Last updated October 3, 2026

You can make a Three.js scene multiplayer without writing or hosting a server. Each player writes their own position and heading, the GameRelay SDK sends and smooths them, and your render loop draws everyone.

How it works

Three.js renders; GameRelay syncs. Anything other players need to see is an entity with a few typed fields. The owner writes it, and everyone else reads a smoothed copy, drawn about 100 ms in the past so it moves evenly on real networks. You don't write interpolation, send rates or reconnect logic.

1. Install

Create an instance at gamerelay.io/app and copy its public key (gr_pub_…). Then:

npm i three @gamerelay/sdk

2. Connect and declare your entities

import * as THREE from 'three';
import { GameRelay } from '@gamerelay/sdk';

const relay = await GameRelay.connect({ publicKey: 'gr_pub_…' });
const room = (await relay.joinInvite().catch(() => null)) ?? (await relay.quickMatch({ maxPlayers: 8 }));
history.replaceState(null, '', room.inviteUrl()); // share the address bar to invite friends

// x and z on the ground, h the heading. 'angle' fields are smoothed the short way round.
const players = room.define('player', { x: 'number', z: 'number', h: 'angle', color: 'number' });

3. Set up the scene and spawn your player

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 200);
camera.position.set(0, 12, 14);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.append(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x222222, 2));
scene.add(new THREE.GridHelper(40, 40));

// Your player. The slot (0, 1, 2…) is stable for the room, good for colours and spawn points.
const slot = room.players.find((p) => p.id === room.me)?.slot ?? 0;
const colors = [0xff6b1a, 0x4fc3f7, 0x9ccc65, 0xffd54f, 0xba68c8, 0xf06292];
const me = players.spawn({ x: slot * 2 - 4, z: 0, h: 0, color: colors[slot % colors.length] });

4. Move yours, draw everyone's

Simulate in relay.tick at a fixed step and render in setAnimationLoop. Your own entity updates at once; other players' come in smoothed:

const keys = new Set();
addEventListener('keydown', (e) => keys.add(e.key));
addEventListener('keyup', (e) => keys.delete(e.key));
addEventListener('blur', () => keys.clear());

// Simulate at a fixed rate; it keeps running when the tab is hidden.
relay.tick(60, (dt) => {
  const turn = (keys.has('ArrowLeft') ? 1 : 0) - (keys.has('ArrowRight') ? 1 : 0);
  const go = (keys.has('ArrowUp') ? 1 : 0) - (keys.has('ArrowDown') ? 1 : 0);
  me.h += turn * 2.5 * dt;
  me.x -= Math.sin(me.h) * go * 6 * dt;
  me.z -= Math.cos(me.h) * go * 6 * dt;
});

// Draw every player, yours included, in the render loop.
const meshes = new Map(); // entity id → mesh
players.on('remove', (p) => {
  const mesh = meshes.get(p.id);
  if (mesh) scene.remove(mesh);
  meshes.delete(p.id);
});

renderer.setAnimationLoop(() => {
  for (const p of players.all()) {
    let mesh = meshes.get(p.id);
    if (!mesh) {
      mesh = new THREE.Mesh(new THREE.ConeGeometry(0.5, 1.2, 12), new THREE.MeshStandardMaterial({ color: p.color }));
      mesh.rotation.order = 'YXZ';
      mesh.rotation.x = -Math.PI / 2; // point the cone forward
      scene.add(mesh);
      meshes.set(p.id, mesh);
    }
    mesh.position.set(p.x, 0.5, p.z);
    mesh.rotation.y = p.h;
  }
  renderer.render(scene, camera);
});

Open two tabs to see two players. Send the address to a friend to play together.

Send rate and precision

Entities send what changed about 20 times a second, rounded to 0.01. A racing or action game can ask for more, up to 60 a second:

// A fast game: send this kind 30 times a second instead of 20, to 1 cm.
const players = room.define('player', { x: { type: 'number', precision: 0.01 }, z: 'number', h: 'angle' }, { rate: 30 });

Rotations: use an 'angle' field (radians) for a heading, so smoothing turns from 359° to 1° the short way. For full 3D orientation, sync yaw and pitch as two angles; a quaternion isn't smoothed.

Shared objects and physics

A ball, a door or an enemy belongs to the world, not to a player. Spawn it on the host with { owner: 'host' }: the host simulates it, and if the host leaves, the next host carries on from the same state. With a physics engine (cannon-es, Rapier), run the shared bodies on the host only, and give other players' copies kinematic bodies that follow the entity.

Common pitfalls

  • Key your meshes by entity id and remove them on 'remove', or players who left stay in the scene.
  • Don't add your own smoothing on top. Other players are already interpolated; lerping again makes them lag and float.
  • Respawns: set the new position and call me.teleport() in the same frame.
  • Big assets: GameRelay syncs game state, not files. Load models and textures from your own host as usual.

Try it

GameRelay has a free plan, no card needed. Get a public key, then follow the quickstart, or point your AI coding agent at llms.txt.