3D Physics Game
Build a 3D multiplayer physics game where a Rivet Actor runs Rapier on the server and players render the world with three.js.
What we’ll build:
- A
physics3dWorldActor that owns a Rapier 3D world with players, walls, and cubes. - A 10 tick per second loop that applies player input and steps the physics.
- Cube positions saved in Actor state, so the world survives a restart.
- A three.js client that sends input and smooths the snapshots it receives.
Install
npm add rivetkit @dimforge/rapier3d-compat three
Create the world Actor
The physics3dWorld Actor holds the Rapier world and gives each connection a body. The scene layout lives in a config file that the server and the client both import:
onBeforeConnect: (_c, params: { name?: string }) => {
const name = params?.name?.trim();
if (!name) {
throw new UserError("name required", { code: "auth_required" });
}
if (name.length > 20) {
throw new UserError("name too long", { code: "invalid_name" });
}
},
state: {
tick: 0,
// [id, x, y, z, hx, hy, hz] — persisted every tick so positions survive restarts.
boxes: [] as [string, number, number, number, number, number, number][],
},
createVars: () => ({
world: null as RAPIER.World | null,
players: {} as Record<string, PlayerEntry>,
dynamicHandles: {} as Record<string, number>,
dynamicSizes: {} as Record<
string,
{ hx: number; hy: number; hz: number }
>,
}),
onConnect: (c, conn) => {
const name = (conn.params as { name?: string })?.name || "Player";
const world = c.vars.world;
if (!world) return;
const angle = Math.random() * Math.PI * 2;
const dist = 2 + Math.random() * 3;
const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(Math.cos(angle) * dist, 2, Math.sin(angle) * dist)
.setCcdEnabled(true)
.lockRotations();
const body = world.createRigidBody(bodyDesc);
const colliderDesc = RAPIER.ColliderDesc.ball(PLAYER_RADIUS)
.setFriction(0.5)
.setRestitution(0);
world.createCollider(colliderDesc, body);
c.vars.players[conn.id] = {
connId: conn.id,
name,
color: getPlayerColor(conn.id),
bodyHandle: body.handle,
inputX: 0,
inputZ: 0,
jump: false,
disconnectedAt: null,
};
broadcastSnapshot(c);
},
onDisconnect: (c, conn) => {
const player = c.vars.players[conn.id];
if (!player) return;
player.disconnectedAt = Date.now();
broadcastSnapshot(c);
},
export const TICK_MS = 100; // 10 TPS
export const SUB_STEPS = 4; // Physics sub-steps per tick for stable stacking.
export const MOVE_FORCE = 0.5;
export const JUMP_IMPULSE = 2;
export const PLAYER_RADIUS = 0.4;
export const CORRECTION_ALPHA = 0.3;
// Static bodies.
export const SCENE_STATIC = [
{ id: "ground", x: 0, y: -0.5, z: 0, hx: 10, hy: 0.5, hz: 10 },
{ id: "wall-n", x: 0, y: 1, z: -10.25, hx: 10, hy: 1.5, hz: 0.25 },
{ id: "wall-s", x: 0, y: 1, z: 10.25, hx: 10, hy: 1.5, hz: 0.25 },
{ id: "wall-w", x: -10.25, y: 1, z: 0, hx: 0.25, hy: 1.5, hz: 10.5 },
{ id: "wall-e", x: 10.25, y: 1, z: 0, hx: 0.25, hy: 1.5, hz: 10.5 },
];
// Dynamic bodies.
export const SCENE_DYNAMIC = [
{ id: "cube-0", x: 2, y: 0.5, z: 0, hx: 0.5, hy: 0.5, hz: 0.5 },
{ id: "cube-1", x: -1, y: 0.5, z: 2, hx: 0.5, hy: 0.5, hz: 0.5 },
{ id: "cube-2", x: 0, y: 0.5, z: -3, hx: 0.5, hy: 0.5, hz: 0.5 },
{ id: "cube-3", x: 3, y: 0.5, z: 3, hx: 0.4, hy: 0.4, hz: 0.4 },
{ id: "cube-4", x: -3, y: 0.5, z: -1, hx: 0.3, hy: 0.6, hz: 0.3 },
{ id: "cube-5", x: 0, y: 1.5, z: 0, hx: 0.5, hy: 0.5, hz: 0.5 },
];
- The Rapier
Worldis a WebAssembly object that can’t be saved, so it lives inc.varsfromcreateVars.c.stateonly holdstickandboxes, plain tuples of each cube’s id, position, and half extents. onBeforeConnectrejects a connection with noname, or one longer than 20 characters, by throwing aUserErrorthat the client receives.onConnectcreates a ball body at a random point around the center and stores its handle underconn.id.lockRotations()keeps the ball from rolling, so input moves it instead of spinning it. If a client connects beforerunhas built the world,onConnectreturns without creating a body.onDisconnectonly marks the player. The tick loop removes the body afterDISCONNECT_GRACE_MS(5 seconds), and snapshots stop including it right away. A reconnecting client gets a newconn.idand a new body.- The scene is a 20 by 20 floor with four walls. y points up, so gravity is -9.81.
Run the physics loop
The simulation runs in run, which starts when the Actor wakes:
run: async (c) => {
await RAPIER.init();
const gravity = new RAPIER.Vector3(0, -9.81, 0);
const world = new RAPIER.World(gravity);
world.timestep = TICK_MS / 1000 / SUB_STEPS;
c.vars.world = world;
// Create static bodies.
for (const s of SCENE_STATIC) {
const bodyDesc = RAPIER.RigidBodyDesc.fixed().setTranslation(
s.x,
s.y,
s.z,
);
const body = world.createRigidBody(bodyDesc);
const colliderDesc = RAPIER.ColliderDesc.cuboid(s.hx, s.hy, s.hz)
.setFriction(0.8)
.setRestitution(0);
world.createCollider(colliderDesc, body);
}
// Load dynamic bodies from persisted state, or seed from config on first run.
const boxes =
c.state.boxes.length > 0
? c.state.boxes
: SCENE_DYNAMIC.map(
(
d,
): [
string,
number,
number,
number,
number,
number,
number,
] => [d.id, d.x, d.y, d.z, d.hx, d.hy, d.hz],
);
for (const [id, x, y, z, hx, hy, hz] of boxes) {
const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(x, y, z)
.setLinearDamping(0.5)
.setCcdEnabled(true);
const body = world.createRigidBody(bodyDesc);
const colliderDesc = RAPIER.ColliderDesc.cuboid(hx, hy, hz)
.setFriction(0.6)
.setRestitution(0);
world.createCollider(colliderDesc, body);
c.vars.dynamicHandles[id] = body.handle;
c.vars.dynamicSizes[id] = { hx, hy, hz };
}
const tick = interval(TICK_MS);
while (!c.aborted) {
await tick();
if (c.aborted) break;
c.state.tick += 1;
const now = Date.now();
for (const [id, player] of Object.entries(c.vars.players)) {
if (
player.disconnectedAt &&
now - player.disconnectedAt > DISCONNECT_GRACE_MS
) {
const body = world.getRigidBody(player.bodyHandle);
if (body) world.removeRigidBody(body);
delete c.vars.players[id];
continue;
}
const body = world.getRigidBody(player.bodyHandle);
if (!body) continue;
if (player.inputX !== 0 || player.inputZ !== 0) {
body.applyImpulse(
new RAPIER.Vector3(
MOVE_FORCE * player.inputX,
0,
MOVE_FORCE * player.inputZ,
),
true,
);
}
if (player.jump) {
player.jump = false;
const vel = body.linvel();
if (Math.abs(vel.y) < 0.5) {
body.applyImpulse(
new RAPIER.Vector3(0, JUMP_IMPULSE, 0),
true,
);
}
}
// Damp horizontal velocity only so gravity isn't affected.
const vel = body.linvel();
body.setLinvel(
new RAPIER.Vector3(vel.x * 0.85, vel.y, vel.z * 0.85),
true,
);
}
for (let i = 0; i < SUB_STEPS; i++) {
world.step();
}
// Persist current dynamic body positions so they survive restarts.
c.state.boxes = [];
for (const [id, handle] of Object.entries(c.vars.dynamicHandles)) {
const body = world.getRigidBody(handle);
if (!body) continue;
const pos = body.translation();
const size = c.vars.dynamicSizes[id];
c.state.boxes.push([
id,
pos.x,
pos.y,
pos.z,
size.hx,
size.hy,
size.hz,
]);
}
broadcastSnapshot(c);
}
},
runawaitsRAPIER.init()to load the WebAssembly module, then adds the fixed bodies fromSCENE_STATICand the cubes fromc.state.boxes. On the first start, the cubes come fromSCENE_DYNAMIC.- Every
TICK_MS(100 ms) the loop applies each player’s input as an impulse on the x and z axes, then callsworld.step()SUB_STEPS(4) times with a 25 ms timestep. Smaller steps keep stacked cubes stable without sending more snapshots. - Velocity on x and z is multiplied by 0.85 each tick so players stop quickly. Vertical velocity is left alone so gravity still works.
- After stepping, it writes every cube position to
c.state.boxesand callsc.broadcast("snapshot", ...)once for all players. Each body in the snapshot carries a quaternion for its rotation. Only position and size are saved, so rotation and velocity reset when the Actor restarts.
Rapier is worth it when you need stacking, joints, many collisions, or shapes like capsules, convex hulls, and triangle meshes. For simple movement, a few lines of kinematic code are cheaper. cannon-es and ammo.js are 3D alternatives. Use one engine per simulation and run it only on the server. three.js only renders, so keep it out of the Actor. The -compat build embeds the WebAssembly, so it loads in Node without bundler setup.
Handle player input
Players send intent. The Actor applies it on the next tick:
setInput: (
c,
input: { inputX: number; inputZ: number; jump?: boolean },
) => {
const player = c.vars.players[c.conn.id];
if (!player) return;
player.inputX = Math.max(-1, Math.min(1, input.inputX));
player.inputZ = Math.max(-1, Math.min(1, input.inputZ));
if (input.jump) player.jump = true;
},
spawnBox: (c, input: { x: number; z: number }) => {
const world = c.vars.world;
if (!world) return;
const id = `spawned-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const h = 0.2 + Math.random() * 0.3;
const rotation = randomQuaternion();
const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(input.x, 5, input.z)
.setRotation(rotation)
.setLinearDamping(0.5)
.setCcdEnabled(true);
const body = world.createRigidBody(bodyDesc);
const colliderDesc = RAPIER.ColliderDesc.cuboid(h, h, h)
.setFriction(0.6)
.setRestitution(0);
world.createCollider(colliderDesc, body);
c.vars.dynamicHandles[id] = body.handle;
c.vars.dynamicSizes[id] = { hx: h, hy: h, hz: h };
broadcastSnapshot(c);
},
setInputstores the x and z direction clamped to -1 to 1 and sets a jump flag. Clients never send positions, so they can’t teleport or move faster than the physics allows.- The tick loop only applies a jump when vertical speed is under 0.5, a cheap check that the player is on something.
spawnBoxdrops a cube of random size and rotation from 5 units above the clicked point. It goes intodynamicHandles, so the next tick saves it with the rest.
spawnBox has no rate limit, body cap, or bounds check, so any client can fill the world. Limit how often each connection can call it and cap the number of bodies before you ship.
Register the Actors
Add the world Actor to the registry and start it:
import { setup } from "rivetkit";
import { arenaMatch } from "./arena/match.ts";
import { arenaMatchmaker } from "./arena/matchmaker.ts";
import { battleRoyaleMatch } from "./battle-royale/match.ts";
import { battleRoyaleMatchmaker } from "./battle-royale/matchmaker.ts";
import { idleLeaderboard } from "./idle/leaderboard.ts";
import { idleWorld } from "./idle/world.ts";
import { ioStyleMatch } from "./io-style/match.ts";
import { ioStyleMatchmaker } from "./io-style/matchmaker.ts";
import { openWorldChunk } from "./open-world/chunk.ts";
import { partyMatch } from "./party/match.ts";
import { partyMatchmaker } from "./party/matchmaker.ts";
import { physics2dWorld } from "./physics-2d/world.ts";
import { physics3dWorld } from "./physics-3d/world.ts";
import { rankedLeaderboard } from "./ranked/leaderboard.ts";
import { rankedMatch } from "./ranked/match.ts";
import { rankedMatchmaker } from "./ranked/matchmaker.ts";
import { rankedPlayer } from "./ranked/player.ts";
import { turnBasedMatch } from "./turn-based/match.ts";
import { turnBasedMatchmaker } from "./turn-based/matchmaker.ts";
export {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatch,
ioStyleMatchmaker,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
};
export const registry = setup({
use: {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatchmaker,
ioStyleMatch,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
},
});
physics3dWorldis the only Actor this game needs. There is no matchmaker, because every player joins the same world.- The example registers every game type in one registry. In your own project,
setup({ use: { physics3dWorld } })is enough. registry.start()serves the Actors. The example’s browser client connects tohttp://localhost:6420.
Connect players
The browser connects to the world, sends input when it changes, and renders each snapshot with three.js:
import * as THREE from "three";
import {
CORRECTION_ALPHA,
PLAYER_RADIUS,
SCENE_STATIC,
} from "../../../src/actors/physics-3d/config.ts";
import type { Physics3dConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { Physics3dMatchInfo } from "./menu.tsx";
interface BodySnapshot {
id: string;
x: number;
y: number;
z: number;
hx: number;
hy: number;
hz: number;
qx: number;
qy: number;
qz: number;
qw: number;
}
interface Snapshot {
tick: number;
serverTime: number;
bodies: BodySnapshot[];
players: Record<
string,
{ x: number; y: number; z: number; name: string; color: string }
>;
}
function colorFromId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
const color = new THREE.Color();
color.setHSL(hue / 360, 0.7, 0.55);
return color.getHex();
}
export class Physics3dGame {
private stopped = false;
private rafId = 0;
private keys: Record<string, boolean> = {};
private lastIx = 0;
private lastIz = 0;
private lastJump = false;
private myConnId = "";
private playerMeta: Record<string, { name: string; color: string }> = {};
private lastSnapshotTime = 0;
private tickIntervalMs = 0;
private latencyMs = 0;
private hudEl: HTMLDivElement;
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private dynamicMeshes: Record<string, THREE.Mesh> = {};
private playerMeshes: Record<string, THREE.Mesh> = {};
private playerLabels: Record<string, THREE.Sprite> = {};
private targets: Record<string, BodySnapshot> = {};
private raycaster = new THREE.Raycaster();
private groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
private conn: Physics3dConn;
constructor(
private container: HTMLDivElement,
client: GameClient,
matchInfo: Physics3dMatchInfo,
) {
// Three.js setup.
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x111111);
this.scene.fog = new THREE.Fog(0x111111, 30, 60);
this.camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
this.camera.position.set(0, 12, 15);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(600, 600);
this.renderer.shadowMap.enabled = true;
this.renderer.domElement.style.display = "block";
container.style.position = "relative";
container.appendChild(this.renderer.domElement);
// HUD overlay.
this.hudEl = document.createElement("div");
this.hudEl.style.cssText =
"position:absolute;top:4px;right:8px;color:#fff;font:11px monospace;background:rgba(0,0,0,0.6);padding:4px 8px;border-radius:4px;pointer-events:none;text-align:right;";
container.appendChild(this.hudEl);
// Lighting.
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambient);
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(5, 10, 5);
dir.castShadow = true;
dir.shadow.mapSize.set(1024, 1024);
dir.shadow.camera.near = 0.5;
dir.shadow.camera.far = 50;
dir.shadow.camera.left = -20;
dir.shadow.camera.right = 20;
dir.shadow.camera.top = 20;
dir.shadow.camera.bottom = -20;
this.scene.add(dir);
// Grid helper.
const grid = new THREE.GridHelper(40, 40, 0x333333, 0x222222);
grid.position.y = 0.01;
this.scene.add(grid);
// Static bodies (ground).
for (const s of SCENE_STATIC) {
const geo = new THREE.BoxGeometry(s.hx * 2, s.hy * 2, s.hz * 2);
const mat = new THREE.MeshStandardMaterial({ color: 0x3a3a3c });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(s.x, s.y, s.z);
mesh.receiveShadow = true;
this.scene.add(mesh);
}
// Connect to actor.
const handle = client.physics3dWorld.getOrCreate(["main"], {
params: { name: matchInfo.name },
});
this.conn = handle.connect();
this.conn.on("snapshot", (snap) => {
const now = Date.now();
if (this.lastSnapshotTime > 0) {
this.tickIntervalMs = now - this.lastSnapshotTime;
}
this.lastSnapshotTime = now;
this.latencyMs = Math.max(0, now - snap.serverTime);
for (const [id, info] of Object.entries(snap.players)) {
this.playerMeta[id] = { name: info.name, color: info.color };
if (info.name === matchInfo.name && !this.myConnId) {
this.myConnId = id;
}
}
for (const body of snap.bodies) {
this.targets[body.id] = body;
// Create meshes for dynamic bodies we don't have yet (e.g. spawned cubes).
if (
!body.id.startsWith("player-") &&
!this.dynamicMeshes[body.id]
) {
const geo = new THREE.BoxGeometry(
body.hx * 2,
body.hy * 2,
body.hz * 2,
);
const mat = new THREE.MeshStandardMaterial({
color: colorFromId(body.id),
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(body.x, body.y, body.z);
mesh.quaternion.set(body.qx, body.qy, body.qz, body.qw);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
this.dynamicMeshes[body.id] = mesh;
}
}
// Remove stale bodies.
const currentIds = new Set(snap.bodies.map((b) => b.id));
for (const id of Object.keys(this.targets)) {
if (!currentIds.has(id)) {
delete this.targets[id];
if (this.dynamicMeshes[id]) {
this.scene.remove(this.dynamicMeshes[id]);
delete this.dynamicMeshes[id];
}
}
}
// Manage player meshes.
for (const [id, info] of Object.entries(snap.players)) {
if (!this.playerMeshes[id]) {
const geo = new THREE.SphereGeometry(PLAYER_RADIUS, 16, 12);
const mat = new THREE.MeshStandardMaterial({
color: info.color,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
this.scene.add(mesh);
this.playerMeshes[id] = mesh;
// Name label sprite.
const label = this.createLabel(info.name);
this.scene.add(label);
this.playerLabels[id] = label;
} else {
const mesh = this.playerMeshes[id];
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.color.set(info.color);
}
}
// Remove disconnected player meshes.
for (const id of Object.keys(this.playerMeshes)) {
if (!snap.players[id]) {
this.scene.remove(this.playerMeshes[id]);
this.scene.remove(this.playerLabels[id]);
delete this.playerMeshes[id];
delete this.playerLabels[id];
delete this.playerMeta[id];
}
}
});
this.renderer.domElement.addEventListener("click", this.onClick);
window.addEventListener("keydown", this.onKeyDown);
window.addEventListener("keyup", this.onKeyUp);
this.rafId = requestAnimationFrame(this.draw);
}
destroy() {
this.stopped = true;
cancelAnimationFrame(this.rafId);
this.renderer.domElement.removeEventListener("click", this.onClick);
window.removeEventListener("keydown", this.onKeyDown);
window.removeEventListener("keyup", this.onKeyUp);
this.conn.dispose().catch(() => {});
this.renderer.dispose();
if (this.container.contains(this.renderer.domElement)) {
this.container.removeChild(this.renderer.domElement);
}
if (this.container.contains(this.hudEl)) {
this.container.removeChild(this.hudEl);
}
}
private createLabel(text: string): THREE.Sprite {
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.font = "bold 28px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, 128, 32);
const texture = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({
map: texture,
depthTest: false,
});
const sprite = new THREE.Sprite(mat);
sprite.scale.set(2, 0.5, 1);
return sprite;
}
private onClick = (e: MouseEvent) => {
const rect = this.renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
this.raycaster.setFromCamera(mouse, this.camera);
const hit = new THREE.Vector3();
if (this.raycaster.ray.intersectPlane(this.groundPlane, hit)) {
this.conn.spawnBox({ x: hit.x, z: hit.z }).catch(() => {});
}
};
private onKeyDown = (e: KeyboardEvent) => {
this.keys[e.key] = true;
this.sendInput();
};
private onKeyUp = (e: KeyboardEvent) => {
this.keys[e.key] = false;
this.sendInput();
};
private sendInput() {
let ix = 0;
let iz = 0;
if (this.keys.a || this.keys.A || this.keys.ArrowLeft) ix -= 1;
if (this.keys.d || this.keys.D || this.keys.ArrowRight) ix += 1;
if (this.keys.w || this.keys.W || this.keys.ArrowUp) iz -= 1;
if (this.keys.s || this.keys.S || this.keys.ArrowDown) iz += 1;
const jump = !!this.keys[" "];
if (
ix !== this.lastIx ||
iz !== this.lastIz ||
(jump && !this.lastJump)
) {
this.lastIx = ix;
this.lastIz = iz;
this.lastJump = jump;
this.conn
.setInput({ inputX: ix, inputZ: iz, jump })
.catch(() => {});
}
if (!jump && this.lastJump) {
this.lastJump = false;
}
}
private draw = () => {
if (this.stopped) return;
// Lerp dynamic scene bodies.
for (const [id, mesh] of Object.entries(this.dynamicMeshes)) {
const target = this.targets[id];
if (!target) continue;
mesh.position.x += (target.x - mesh.position.x) * CORRECTION_ALPHA;
mesh.position.y += (target.y - mesh.position.y) * CORRECTION_ALPHA;
mesh.position.z += (target.z - mesh.position.z) * CORRECTION_ALPHA;
const targetQuat = new THREE.Quaternion(
target.qx,
target.qy,
target.qz,
target.qw,
);
mesh.quaternion.slerp(targetQuat, CORRECTION_ALPHA);
}
// Lerp player meshes.
for (const [id, mesh] of Object.entries(this.playerMeshes)) {
const target = this.targets[`player-${id}`];
if (!target) continue;
mesh.position.x += (target.x - mesh.position.x) * CORRECTION_ALPHA;
mesh.position.y += (target.y - mesh.position.y) * CORRECTION_ALPHA;
mesh.position.z += (target.z - mesh.position.z) * CORRECTION_ALPHA;
// Position label above player.
const label = this.playerLabels[id];
if (label) {
label.position.copy(mesh.position);
label.position.y += PLAYER_RADIUS + 0.5;
}
}
// Camera follows the local player.
const myMesh = this.playerMeshes[this.myConnId];
if (myMesh) {
const target = myMesh.position;
this.camera.position.x +=
(target.x - this.camera.position.x) * 0.05;
this.camera.position.z +=
(target.z + 8 - this.camera.position.z) * 0.05;
this.camera.position.y +=
(target.y + 6 - this.camera.position.y) * 0.05;
this.camera.lookAt(target.x, target.y, target.z);
}
this.renderer.render(this.scene, this.camera);
// Update HUD.
const tps =
this.tickIntervalMs > 0
? (1000 / this.tickIntervalMs).toFixed(1)
: "—";
this.hudEl.textContent = `TPS: ${tps} Interval: ${this.tickIntervalMs}ms | Latency: ~${this.latencyMs}ms`;
this.rafId = requestAnimationFrame(this.draw);
};
}
- Every player calls
getOrCreate(["main"]), so everyone shares one world. Use a key per room to run many worlds, each with its own loop. - The
snapshothandler stores target positions and creates a mesh the first time it sees a body, such as a cube someone just spawned. It removes meshes for bodies and players that are gone. - Each frame, the
requestAnimationFrameloop moves meshes 30% (CORRECTION_ALPHA) toward their targets and usesslerpto turn them toward the target quaternion. The camera follows your own ball. sendInputcallssetInputonly when the direction changes or jump is pressed. A click casts a ray onto the ground plane and sends the hit point tospawnBox.- The snapshot doesn’t say which player is you, so the client finds itself by name. Two players with the same name confuse it. Send each player its id with
conn.sendto fix this.
This is server-authoritative netcode with interpolation. The client does no prediction, so your own movement shows up one round trip plus up to one tick later. To make it feel faster, step the same physics for your own body on the client and correct it toward each snapshot. Every player also receives every body. That’s fine for one room. For a large world, filter what each connection receives or split it into chunks as in the Open World Game guide.
Try it
Run the example:
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/multiplayer-game-patterns
pnpm install
pnpm dev
Open http://localhost:5173, choose Physics 3D, and click Join World. Move with WASD, jump with Space, and click the floor to drop cubes. Open a second tab to push the other player around. The top right shows the measured tick rate and latency.
Next steps
- 2D Physics Game: the same pattern with Rapier 2D and a canvas.
- Open World Game: split a large world across many Actors.
- State: when to use
stateand when to usevars.