Skip to main content
Games

2D Physics Game

Build a 2D multiplayer physics game where a Rivet Actor runs Rapier on the server and streams every body's position to the players.

See the full example on GitHub. View on GitHub
Playercanvas clientOther playersame world keyphysics2dWorld10 ticks per secondRapier 2D worldbodies in c.varssetInput, spawnBox1step x42snapshot3setInput, spawnBox1snapshot3One per worldRivet ActorCallReply

What we’ll build:

  • A physics2dWorld Actor that owns a Rapier 2D world with players, platforms, and boxes.
  • A 10 tick per second loop that applies player input and steps the physics.
  • Box positions saved in Actor state, so the world survives a restart.
  • A canvas client that sends input and smooths the snapshots it receives.

Install

npm add rivetkit@3.0.0-preview.1 @dimforge/rapier2d-compat

Create the world Actor

The physics2dWorld 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:

  • The Rapier World is a WebAssembly object that can’t be saved, so it lives in c.vars from createVars. c.state only holds tick and boxes, plain tuples of each box’s id, position, and size.
  • onBeforeConnect rejects a connection with no name, or one longer than 20 characters, by throwing a UserError that the client receives.
  • onConnect creates a ball body for the connection and stores its handle under conn.id. CCD is on so fast bodies don’t pass through the thin platforms. If a client connects before run has built the world, onConnect returns without creating a body.
  • onDisconnect only marks the player. The tick loop removes the body after DISCONNECT_GRACE_MS (5 seconds), and snapshots stop including it right away. A reconnecting client gets a new conn.id and a new body.
  • The scene uses physics units, with SCALE at 50 pixels per unit, and y points down, so gravity is positive.

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.Vector2(0, 20);
	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,
		);
		const body = world.createRigidBody(bodyDesc);
		const colliderDesc = RAPIER.ColliderDesc.cuboid(s.hw, s.hh)
			.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] => [
						d.id,
						d.x,
						d.y,
						d.hw,
						d.hh,
					],
				);
	for (const [id, x, y, hw, hh] of boxes) {
		const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
			.setTranslation(x, y)
			.setLinearDamping(0.5)
			.setCcdEnabled(true);
		const body = world.createRigidBody(bodyDesc);
		const colliderDesc = RAPIER.ColliderDesc.cuboid(hw, hh)
			.setFriction(0.6)
			.setRestitution(0);
		world.createCollider(colliderDesc, body);
		c.vars.dynamicHandles[id] = body.handle;
		c.vars.dynamicSizes[id] = { hw, hh };
	}

	const tick = interval(TICK_MS);
	while (!c.aborted) {
		await tick();
		if (c.aborted) break;
		c.state.tick += 1;

		const now = Date.now();

		// Apply player forces and remove disconnected players.
		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) {
				body.applyImpulse(
					new RAPIER.Vector2(MOVE_FORCE * player.inputX, 0),
					true,
				);
			}

			if (player.jump) {
				player.jump = false;
				const vel = body.linvel();
				if (Math.abs(vel.y) < 2) {
					body.applyImpulse(
						new RAPIER.Vector2(0, -JUMP_IMPULSE),
						true,
					);
				}
			}

			// Damp horizontal velocity only so gravity isn't affected.
			const vel = body.linvel();
			body.setLinvel(new RAPIER.Vector2(vel.x * 0.85, vel.y), 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, size.hw, size.hh]);
		}

		broadcastSnapshot(c);
	}
},
  • run awaits RAPIER.init() to load the WebAssembly module, then adds the fixed bodies from SCENE_STATIC and the boxes from c.state.boxes. On the first start, the boxes come from SCENE_DYNAMIC.
  • Every TICK_MS (100 ms) the loop applies each player’s input as an impulse, then calls world.step() SUB_STEPS (4) times with a 25 ms timestep. Smaller steps keep stacked boxes stable without sending more snapshots.
  • Horizontal velocity 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 box position to c.state.boxes and calls c.broadcast("snapshot", ...) once for all players. 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 rotated shapes. For simple movement, a few lines of kinematic code are cheaper. planck-js and matter-js are 2D alternatives. Use one engine per simulation and run it only on the server. 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; jump?: boolean }) => {
	const player = c.vars.players[c.conn.id];
	if (!player) return;
	player.inputX = Math.max(-1, Math.min(1, input.inputX));
	if (input.jump) player.jump = true;
},
spawnBox: (c, input: { x: number; y: number }) => {
	const world = c.vars.world;
	if (!world) return;
	const id = `spawned-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
	const hw = 0.2 + Math.random() * 0.2;
	const hh = 0.2 + Math.random() * 0.2;
	const angle = Math.random() * Math.PI * 2;

	const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
		.setTranslation(input.x, input.y)
		.setRotation(angle)
		.setLinearDamping(0.5)
		.setCcdEnabled(true);
	const body = world.createRigidBody(bodyDesc);
	const colliderDesc = RAPIER.ColliderDesc.cuboid(hw, hh)
		.setFriction(0.6)
		.setRestitution(0);
	world.createCollider(colliderDesc, body);
	c.vars.dynamicHandles[id] = body.handle;
	c.vars.dynamicSizes[id] = { hw, hh };
	broadcastSnapshot(c);
},
  • setInput stores the 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 2, a cheap check that the player is on something.
  • spawnBox drops a box of random size and rotation at the click point. It goes into dynamicHandles, 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:

  • physics2dWorld is 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: { physics2dWorld } }) is enough.
  • registry.start() serves the Actors. The example’s browser client connects to http://localhost:6420.

Connect players

The browser connects to the world, sends input when it changes, and draws each snapshot on a canvas:

import {
	CORRECTION_ALPHA,
	PLAYER_RADIUS,
	SCALE,
	SCENE_STATIC,
} from "../../../src/actors/physics-2d/config.ts";
import type { Physics2dConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { Physics2dMatchInfo } from "./menu.tsx";

interface BodySnapshot {
	id: string;
	x: number;
	y: number;
	angle: number;
	vx: number;
	vy: number;
	hw: number;
	hh: number;
}

interface Snapshot {
	tick: number;
	serverTime: number;
	bodies: BodySnapshot[];
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
}

interface DisplayBody {
	x: number;
	y: number;
	angle: number;
}

function colorFromId(id: string): string {
	let hash = 0;
	for (let i = 0; i < id.length; i++) {
		hash = (hash * 31 + id.charCodeAt(i)) | 0;
	}
	const hue = ((hash % 360) + 360) % 360;
	return `hsl(${hue}, 70%, 55%)`;
}

export class Physics2dGame {
	private stopped = false;
	private rafId = 0;
	private keys: Record<string, boolean> = {};
	private lastIx = 0;
	private lastJump = false;
	private targets: Record<string, BodySnapshot> = {};
	private display: Record<string, DisplayBody> = {};
	private playerMeta: Record<string, { name: string; color: string }> = {};
	private myConnId = "";
	private lastSnapshotTime = 0;
	private tickIntervalMs = 0;
	private latencyMs = 0;
	private conn: Physics2dConn;

	constructor(
		private canvas: HTMLCanvasElement,
		client: GameClient,
		matchInfo: Physics2dMatchInfo,
	) {
		const handle = client.physics2dWorld.getOrCreate(["main"], {
			params: { name: matchInfo.name },
		});
		this.conn = handle.connect();

		this.conn.on("snapshot", (snap) => {
			const now = Date.now();

			// Measure tick interval and one-way latency estimate.
			if (this.lastSnapshotTime > 0) {
				this.tickIntervalMs = now - this.lastSnapshotTime;
			}
			this.lastSnapshotTime = now;
			this.latencyMs = Math.max(0, now - snap.serverTime);

			// Track player names and find our connection ID from players map.
			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;
				if (!this.display[body.id]) {
					this.display[body.id] = {
						x: body.x,
						y: body.y,
						angle: body.angle,
					};
				}
			}

			// 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];
					delete this.display[id];
				}
			}

			// Remove stale players.
			for (const id of Object.keys(this.playerMeta)) {
				if (!snap.players[id]) {
					delete this.playerMeta[id];
				}
			}
		});

		this.canvas.addEventListener("mousedown", this.onMouseDown);
		window.addEventListener("keydown", this.onKeyDown);
		window.addEventListener("keyup", this.onKeyUp);
		this.rafId = requestAnimationFrame(this.draw);
	}

	destroy() {
		this.stopped = true;
		cancelAnimationFrame(this.rafId);
		this.canvas.removeEventListener("mousedown", this.onMouseDown);
		window.removeEventListener("keydown", this.onKeyDown);
		window.removeEventListener("keyup", this.onKeyUp);
		this.conn.dispose().catch(() => {});
	}

	private onMouseDown = (e: MouseEvent) => {
		const rect = this.canvas.getBoundingClientRect();
		const x = (e.clientX - rect.left) / SCALE;
		const y = (e.clientY - rect.top) / SCALE;
		this.conn.spawnBox({ x, y }).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;
		if (this.keys.a || this.keys.A || this.keys.ArrowLeft) ix -= 1;
		if (this.keys.d || this.keys.D || this.keys.ArrowRight) ix += 1;
		const jump = !!this.keys[" "];

		if (ix !== this.lastIx || (jump && !this.lastJump)) {
			this.lastIx = ix;
			this.lastJump = jump;
			this.conn.setInput({ inputX: ix, jump }).catch(() => {});
		}
		if (!jump && this.lastJump) {
			this.lastJump = false;
		}
	}

	private draw = () => {
		if (this.stopped) return;
		const ctx = this.canvas.getContext("2d")!;
		const W = this.canvas.width;
		const H = this.canvas.height;

		ctx.fillStyle = "#000000";
		ctx.fillRect(0, 0, W, H);

		// Lerp display positions toward server targets.
		for (const [id, target] of Object.entries(this.targets)) {
			const d = this.display[id]!;
			d.x += (target.x - d.x) * CORRECTION_ALPHA;
			d.y += (target.y - d.y) * CORRECTION_ALPHA;

			// Shortest-path angle interpolation to handle wrapping around ±π.
			let angleDiff = target.angle - d.angle;
			angleDiff = ((angleDiff + Math.PI) % (2 * Math.PI)) - Math.PI;
			if (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
			d.angle += angleDiff * CORRECTION_ALPHA;
		}

		// Draw static bodies.
		ctx.fillStyle = "#3a3a3c";
		for (const s of SCENE_STATIC) {
			ctx.fillRect(
				(s.x - s.hw) * SCALE,
				(s.y - s.hh) * SCALE,
				s.hw * 2 * SCALE,
				s.hh * 2 * SCALE,
			);
		}

		// Draw dynamic bodies (from snapshot, includes spawned boxes).
		for (const [id, target] of Object.entries(this.targets)) {
			if (id.startsWith("player-")) continue;
			const disp = this.display[id];
			if (!disp) continue;

			ctx.save();
			ctx.translate(disp.x * SCALE, disp.y * SCALE);
			ctx.rotate(disp.angle);
			ctx.fillStyle = colorFromId(id);
			ctx.fillRect(
				-target.hw * SCALE,
				-target.hh * SCALE,
				target.hw * 2 * SCALE,
				target.hh * 2 * SCALE,
			);
			ctx.restore();
		}

		// Draw player circles.
		for (const [connId, meta] of Object.entries(this.playerMeta)) {
			const bodyId = `player-${connId}`;
			const disp = this.display[bodyId];
			if (!disp) continue;

			const px = disp.x * SCALE;
			const py = disp.y * SCALE;
			const isMe = connId === this.myConnId;

			ctx.beginPath();
			ctx.arc(px, py, PLAYER_RADIUS * SCALE, 0, Math.PI * 2);
			ctx.fillStyle = meta.color;
			ctx.fill();
			if (isMe) {
				ctx.lineWidth = 2;
				ctx.strokeStyle = "#ffffff";
				ctx.stroke();
			}

			ctx.fillStyle = "#ffffff";
			ctx.font = "11px sans-serif";
			ctx.textAlign = "center";
			ctx.fillText(meta.name, px, py - PLAYER_RADIUS * SCALE - 4);
		}

		// HUD: tick rate and latency.
		ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
		ctx.fillRect(W - 150, 4, 146, 40);
		ctx.fillStyle = "#ffffff";
		ctx.font = "11px monospace";
		ctx.textAlign = "right";
		const tps =
			this.tickIntervalMs > 0
				? (1000 / this.tickIntervalMs).toFixed(1)
				: "—";
		ctx.fillText(
			`TPS: ${tps}  Interval: ${this.tickIntervalMs}ms`,
			W - 8,
			18,
		);
		ctx.fillText(`Latency: ~${this.latencyMs}ms`, W - 8, 34);

		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 snapshot handler only stores target positions. Each frame, the requestAnimationFrame loop moves the drawn bodies 30% (CORRECTION_ALPHA) toward their targets and turns angles the short way around.
  • sendInput calls setInput only when the direction changes or jump is pressed, not every frame.
  • 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.send to 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 2D, and click Join World. Move with A and D, jump with Space, and click to drop boxes. Open a second tab to push the other player around. The top right shows the measured tick rate and latency.

Next steps