2D rooms with Rapier

rapier2d is Rapier compiled for the plane. It is the engine to pick for a new 2D game, and a room opts into it with one field:

physics: { engine: 'rapier2d', gravity: { x: 0, y: -9.81 }, setup, bodies }

Everything around it is the same as a rapier3d room: the world steps once per tick on the server, the step writes each body’s pose and velocity into ordinary schema fields, and body movement reaches clients through the same sync path as every other field. There is no separate 2D wire format and no new concept.

Install the engine

npm install @dimforge/rapier2d-compat

It is an optional peer dependency, like the 3D build. The two are separate packages with separate WASM, so a 2D game installs only @dimforge/rapier2d-compat and pays for nothing else.

Why this and not matter2d

matter2d is still supported and still documented, and a game already built on it has no reason to move. For a new 2D game, four things point at Rapier:

  • A woken room is the world that went to sleep. Rapier serializes the whole world, resting contacts included, so hibernation is a snapshot restore. matter.js has no world snapshot, so a matter2d wake rebuilds the world, re-runs setup, and reapplies per-body state, and the solver’s transient state does not survive that.
  • The world applies gravity. A body nobody owns falls on its own, on the server and in a predicting client’s local world. matter2d rooms usually zero engine.gravity and apply gravity per body, which is why the matter2d client option list has a settle hook and this one does not.
  • Velocities are per second. body.linvel() is metres per second, the unit the rest of your game already uses. matter’s body.velocity is a displacement per sixtieth of a second.
  • One API family. Descs, colliders, joints and the query pipeline are the same names as the 3D engine, so a game that grows a third axis changes its imports rather than its habits.

Pick matter.js when you have an existing matter2d room, or when you specifically want plain JavaScript with no WASM to load.

Units are Rapier’s

irtio blesses the engine rather than abstracting it, so nothing here is translated:

  • y points up. Earth gravity is { x: 0, y: -9.81 }.
  • gravity is in world units per second squared, not a multiple of anything.
  • Velocities are per second.
  • Distances are whatever you make them. Metres are the usual choice, and the solver’s defaults are tuned for a body about a metre across.

The shared world module

The client cannot import your room file, so the parts of the world both sides need live in their own module and both sides import it. By convention that file is irtio/world.ts. It is the same arrangement client prediction describes for the 3D engine.

// irtio/world.ts
import type { Rapier2dModule, Rapier2dRigidBody, Rapier2dWorld } from '@irtio/server';

export const gravity = { x: 0, y: -9.81 } as const;
export const TICK_RATE = 30;
// Say it, do not inherit it: WELCOME carries the tick interval as a whole number of
// milliseconds, so a 60 Hz room arrives as 17 and every local step runs long.
export const timestep = 1 / TICK_RATE;

export const CRATE_HALF = 0.15;
export const PUSH = 0.05;

/** Static geometry. Takes nothing but `world` and `rapier`, so both sides build it identically. */
export function setup(world: Rapier2dWorld, R: Rapier2dModule): void {
  const floor = world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(0, 0));
  world.createCollider(R.ColliderDesc.cuboid(6, 0.2), floor);
  for (const side of [-1, 1]) {
    const wall = world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(side * 5, 3));
    world.createCollider(R.ColliderDesc.cuboid(0.2, 3), wall);
  }
}

export const bodies = {
  crates: (R: Rapier2dModule) => ({
    body: R.RigidBodyDesc.dynamic(),
    colliders: [R.ColliderDesc.cuboid(CRATE_HALF, CRATE_HALF).setRestitution(0.1)],
  }),
};

export const intents = {
  crates: (body: Rapier2dRigidBody, crate: Record<string, unknown>): void => {
    const push = typeof crate.push === 'number' ? crate.push : 0;
    if (push === 0) return;
    const v = body.linvel();
    // Metres per second, unlike matter's per-step displacement.
    body.setLinvel({ x: v.x + push * PUSH, y: v.y }, true);
  },
};

There is deliberately no settle export. The world has gravity, so an unowned crate falls without one.

The room config

// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
import * as world from './world.js';

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: world.TICK_RATE,

  physics: {
    engine: 'rapier2d',
    gravity: world.gravity,
    timestep: world.timestep,
    setup: world.setup,
    bodies: world.bodies,
    intents: { crates: world.intents.crates },
  },

  tick(state, _dt, room) {
    for (const [id, crate] of state.crates) {
      const body = room.physicsRapier2d.body('crates', id);
      if (!body) continue;
      world.intents.crates(body, crate as unknown as Record<string, unknown>);
    }
  },
});
keywhat it does
engine'rapier2d'.
gravity{ x, y }, in world units per second squared. y is up.
timestepSeconds per step. Defaults to 1 / tickRate. One step per tick, no substeps.
setup(world, rapier, room)Builds static geometry, joints and world tuning.
bodies.<collection>(rapier, instance, id) => { body, colliders? }, one entry per physics collection.
intents.<collection>The shared steering function for a collection, optional.
historyTicks of pose history to keep for lag compensation, 0 to 240.

setup builds static geometry once per world. bodies.<collection> returns a { body, colliders } pair, because a Rapier rigid body carries no shape of its own. The spawn pose comes from the schema: the runtime applies the instance’s mapped channels after the desc, so an entity added at y: 6 starts its fall from six units up, and channels you do not map keep whatever the factory chose.

intents is a declaration, not a hook the runtime runs. Your tick() runs the steering, exactly as the example above does. What the field buys is one naming: joinRoom({ physics2d: { intents } }) on the client and physics: { intents } here point at the same function in the same shared module, so “same code both sides” is something defineRoom can check rather than a convention. It refuses a key that names a collection whose schema declares no intents.

defineRoom also refuses mode: 'event' with physics, a non-finite or non-planar gravity, a bodies map that misses a collection or names one with no schema physics, and a history outside 0 to 240.

Reading the world in handlers: room.physicsRapier2d

tick(state, _dt, room) {
  const body = room.physicsRapier2d.body('crates', id);
  const R = room.physicsRapier2d.rapier;
  room.physicsRapier2d.world.createCollider(R.ColliderDesc.ball(0.2), body);
}

room.physicsRapier2d gives you .rapier (the namespace), .world (the live World), .timestep, and .body(collection, id).

It is a third accessor rather than a narrowing of room.physics, so that every existing room keeps working untouched. Reading room.physics or room.physics2d in a rapier2d room throws and names room.physicsRapier2d, and the reverse throws too. One line of reading rather than a debugging session.

Mapping 2D onto the state channels

The schema’s body channels are 3D names, deliberately: the schema never picks a dimensionality. A rapier2d room maps the ones a plane has, the same set a matter2d room maps:

rapier2dchannel
translation().xx
translation().yy
rotation()qz = sin(angle/2), qw = cos(angle/2)
linvel().x / linvel().yvx / vy
angvel()wz
export const schema = defineSchema({
  crates: entity(
    { x: f32, y: f32, vx: f32, vy: f32, qz: f32, qw: f32, wz: f32, push: f32 },
    {
      physics: {
        body: { x: 'x', y: 'y', vx: 'vx', vy: 'vy', qz: 'qz', qw: 'qw', wz: 'wz' },
        intents: ['push'],
      },
    },
  ),
});

z, qx, qy, vz, wx and wy are constant zero if you map them at all, and most 2D games do not. To read the heading back on a client:

import { angleFrom2d } from '@irtio/schema';
const angle = angleFrom2d(crate.qz, crate.qw);

angleFrom2d, channelOf2d and applyChannel2d are the one place this mapping is written down, and both sides of the wire use them, as do both 2D engines. No wire format changed to add 2D.

Prediction

A rapier2d room predicts through joinRoom({ physics2d }), the same option a matter2d room uses. The engine field is what tells them apart, and it is required here: a physics2d block without one is a matter2d block, so pre-existing clients keep compiling.

joinRoom(schema, {
  physics2d: {
    engine: 'rapier2d',
    gravity: world.gravity,
    timestep: world.timestep,
    setup: world.setup,
    bodies: world.bodies,
    intents: { crates: world.intents.crates },
  },
});
optiondefault
enginerequired, 'rapier2d'
gravityrequired, and it has to match the room’s
timestepthe room’s tick interval from WELCOME, rounded to whole milliseconds
setup, bodies, intentsnone
maxPredictedBodies64
maxProxyBodies256
epsilon0.05 world units
smoothingHalfLifeMs70, 0 disables
smoothingSnapUnits4

There is no settle, and there should not be: the local world runs the same gravity the room does, so a crate nobody owns falls locally instead of hanging in the air for the length of the prediction lead. The loop, the cap, the proxies and room.prediction are the ones client prediction describes, and they read identically here.

The intent hook takes the same five arguments in the same order on both sides (body, instance, rapier, world, timestep), so one function in irtio/world.ts typechecks against the room config and the client options without a cast.

Because velocities are per second, the correction-suppression tolerance for a velocity channel is epsilon / timestep: one tick of an epsilon-sized position error. A body the client does not simulate, past the prediction cap or in a collection that is not predicted, gets a kinematic proxy driven to the pose the renderer draws. Rapier switches the body type rather than rebuilding it, so a proxy keeps the factory’s collider and material.

Sleeping and waking

Rapier serializes its whole world, contacts included, so a rapier2d wake is bit-exact on the same build on the same machine, and a crate that was mid-flight when the room went to sleep resumes its arc. setup does not run on an ordinary wake, because the static geometry came back inside the snapshot. It runs only when the world is genuinely rebuilt: a snapshot written before the room had physics, or a schema migration, both of which drop the saved world and rebuild from schema state.

The snapshot format did not change to accommodate the third engine. The engine tag lives inside the existing format-2 physics section, and every blob written before rapier2d existed is byte-identical and still reads.

Two limits to plan around:

  • The world dominates the snapshot, as it does in 3D. If your game hibernates often, the world is the thing to size, not the state.
  • Determinism is per build and per machine. Save and restore round-trip a world bit-exactly on the same build on the same machine, which is what hibernation needs. Cross-platform determinism is a stronger claim, it needs Rapier’s enhanced-determinism build, and irtio does not make it.

Simulate

irtio simulate supports rapier2d. It reads the engine from the room’s own physics.engine rather than guessing from the shape of gravity, so a 2D Rapier room is recognised for what it is, and bots predict with the same physics2d: { engine: 'rapier2d' } options a real client would pass.

Before the bots start, the world builder is built twice and the two worlds compared byte for byte through takeSnapshot(), body factories included. A builder that reads a clock or a random number fails there rather than as a drift you chase later. The run then reports how many within-epsilon corrections were suppressed.

A rapier2d world is predictable to simulate when it exports bodies plus either intents or a non-zero gravity. It never asks for settle.

Next steps