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.
What we’ll build:
- A
physics2dWorldActor 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 SQLite, so the world survives a restart.
- A canvas client that sends input and smooths the snapshots it receives.
Install
npm add rivetkit @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:
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" });
}
},
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS boxes (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
hw REAL NOT NULL,
hh REAL NOT NULL
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS world (
id INTEGER PRIMARY KEY CHECK (id = 1),
tick INTEGER NOT NULL
)
`);
},
}),
createVars: () => ({
tick: 0,
world: null as RAPIER.World | null,
players: {} as Record<string, PlayerEntry>,
dynamicHandles: {} as Record<string, number>,
dynamicSizes: {} as Record<string, { hw: number; hh: number }>,
// `onWake` stores its physics loop here so `onSleep` and `onDestroy` can
// wait for it.
loop: null as Promise<void> | null,
}),
onConnect: (c, conn) => {
const name = (conn.params as { name?: string })?.name || "Player";
const world = getWorld(c);
const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(3 + Math.random() * 6, 5)
.setCcdEnabled(true);
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,
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 SNAPSHOT_INTERVAL_MS = 1_000; // Saving this often bounds how much progress a crash can lose.
export const SUB_STEPS = 4; // Physics sub-steps per tick for stable stacking.
export const MOVE_FORCE = 1.5;
export const JUMP_IMPULSE = 4;
export const PLAYER_RADIUS = 0.3;
export const CORRECTION_ALPHA = 0.3;
// Scale: 50 pixels per physics unit. Canvas 600x600 = 12x12 units.
export const SCALE = 50;
// Static bodies (immovable).
export const SCENE_STATIC = [
{ id: "ground", x: 6, y: 11.75, hw: 6, hh: 0.25 },
{ id: "wall-l", x: -0.25, y: 6, hw: 0.25, hh: 6 },
{ id: "wall-r", x: 12.25, y: 6, hw: 0.25, hh: 6 },
{ id: "plat-1", x: 3, y: 8, hw: 1.5, hh: 0.12 },
{ id: "plat-2", x: 9, y: 6.5, hw: 1.5, hh: 0.12 },
];
// Dynamic bodies (affected by physics).
export const SCENE_DYNAMIC = [
{ id: "box-0", x: 6, y: 10.5, hw: 0.35, hh: 0.35 },
{ id: "box-1", x: 6.05, y: 9.8, hw: 0.35, hh: 0.35 },
{ id: "box-2", x: 5.95, y: 9.1, hw: 0.35, hh: 0.35 },
{ id: "box-3", x: 2, y: 10.5, hw: 0.3, hh: 0.3 },
{ id: "box-4", x: 10, y: 10.5, hw: 0.25, hh: 0.4 },
{ id: "box-5", x: 7.5, y: 10.5, hw: 0.4, hh: 0.25 },
];
- The Rapier
Worldis a WebAssembly object that can’t be saved, so it lives inc.vars, along with thetickcounter and the player bodies.createVarsstarts it asnull, andonWakebuilds it in the next step. SQLite keeps a copy of the world: theboxestable, one row per box with its id, position, and size, and a one-rowworldtable with the tick. onBeforeConnectrejects a connection with noname, or one longer than 20 characters, by throwing aUserErrorthat the client receives.onConnectcreates a ball body for the connection and stores its handle underconn.id. CCD is on so fast bodies don’t pass through the thin platforms.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 uses physics units, with
SCALEat 50 pixels per unit, and y points down, so gravity is positive.
Run the physics loop
onWake runs on every start. It loads the world and starts the simulation as a background task, and onSleep and onDestroy wait for that task to save and exit:
// `onWake` runs on every start. The Actor accepts connections and actions
// only after it resolves, so the world is loaded before anyone joins.
onWake: async (c) => {
const world = await loadWorld(c);
c.vars.world = world;
c.vars.loop = runWorld(c, world).catch((error) => {
c.log.error({ msg: "physics loop failed", error });
});
},
// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own.
onSleep: async (c) => {
await c.vars.loop;
},
// The database closes right after `onDestroy`, so wait for the final save.
onDestroy: async (c) => {
await c.vars.loop;
},
- The Actor accepts connections and actions only after
onWakeresolves, soonConnectandspawnBoxalways find the loaded world throughgetWorld. onDestroywaits for the loop too, because the Actor closes its database right afteronDestroyreturns.
loadWorld builds the Rapier world and restores it from SQLite:
/**
* Builds the Rapier world and restores the tick and boxes from SQLite, or seeds
* the boxes from config on the first start.
*/
async function loadWorld(
c: ActorContextOf<typeof physics2dWorld>,
): Promise<RAPIER.World> {
await RAPIER.init();
const gravity = new RAPIER.Vector2(0, 20);
const world = new RAPIER.World(gravity);
world.timestep = TICK_MS / 1000 / SUB_STEPS;
// 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 the last saved world from SQLite, or seed it from config on
// the first run.
const [savedWorld] = await c.db.execute<{ tick: number }>(
"SELECT tick FROM world WHERE id = 1",
);
c.vars.tick = savedWorld?.tick ?? 0;
const savedBoxes = await c.db.execute<BoxRow>(
"SELECT id, x, y, hw, hh FROM boxes",
);
const boxes = savedBoxes.length > 0 ? savedBoxes : SCENE_DYNAMIC;
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 };
}
return world;
}
- It awaits
RAPIER.init()to load the WebAssembly module, then adds the fixed bodies fromSCENE_STATICand restores the tick and the boxes from SQLite. On the first start the tables are empty, so the boxes come fromSCENE_DYNAMIC.
runWorld steps the world until the Actor shuts down:
/**
* Steps the world every `TICK_MS` until the Actor starts shutting down.
*/
async function runWorld(
c: ActorContextOf<typeof physics2dWorld>,
world: RAPIER.World,
): Promise<void> {
const tick = interval(TICK_MS);
let savedAt = Date.now();
while (!c.aborted) {
await tick();
if (c.aborted) break;
c.vars.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();
}
broadcastSnapshot(c);
// Ticks only change memory. Save to SQLite every
// SNAPSHOT_INTERVAL_MS instead of on every tick.
if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
await saveWorld(c, world);
savedAt = now;
}
}
// Save once more when the Actor stops, so a clean restart loses nothing.
await saveWorld(c, world);
}
- Every
TICK_MS(100 ms) the loop applies each player’s input as an impulse, then callsworld.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 calls
c.broadcast("snapshot", ...)once for all players. The simulation itself stays in memory, so ticks don’t write to the database. - Every
SNAPSHOT_INTERVAL_MS(1 second) the loop callssaveWorld, so a crash loses at most about a second of movement. When the Actor sleeps or stops,c.abortedends the loop andrunWorldsaves once more. Only position and size are saved, so rotation and velocity reset when the Actor restarts. - An empty world sleeps after the idle timeout. The next player’s connection wakes it,
onWakerestores the last save, and that player gets a body like everyone else.
saveWorld writes the copy in SQLite:
/**
* Writes the tick and every box position to SQLite in one transaction, so a
* crash leaves either the previous save or this one, never part of both.
*/
async function saveWorld(
c: ActorContextOf<typeof physics2dWorld>,
world: RAPIER.World,
) {
const tick = c.vars.tick;
const boxes: BoxRow[] = [];
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];
boxes.push({ id, x: pos.x, y: pos.y, hw: size.hw, hh: size.hh });
}
await c.db.transaction(
async (tx) => {
await tx.execute(
"INSERT OR REPLACE INTO world (id, tick) VALUES (1, ?)",
tick,
);
await tx.execute("DELETE FROM boxes");
for (const box of boxes) {
await tx.execute(
"INSERT INTO boxes (id, x, y, hw, hh) VALUES (?, ?, ?, ?, ?)",
box.id,
box.x,
box.y,
box.hw,
box.hh,
);
}
},
{ name: "save-world" },
);
}
- It replaces the
worldrow and every row inboxesinside onec.db.transaction. A crash in the middle of a save keeps the previous save instead of leaving part of the world.
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 = getWorld(c);
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);
},
setInputstores 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.
spawnBoxdrops a box of random size and rotation at the click point. It goes intodynamicHandles, so the next save includes it.
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,
},
});
physics2dWorldis 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 tohttp://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
snapshothandler only stores target positions. Each frame, therequestAnimationFrameloop moves the drawn bodies 30% (CORRECTION_ALPHA) toward their targets and turns angles the short way around. sendInputcallssetInputonly 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.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 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
- 3D Physics Game: the same pattern with Rapier 3D and three.js.
- Open World Game: split a large world across many Actors.
- State: when to keep data in SQLite and when to use
vars.