2D rooms with matter.js
irtio blesses two physics engines. Rapier is the 3D one and the default. matter.js is the 2D one, and a room opts into it with one field:
physics: { engine: 'matter2d', gravity: { x: 0, y: 1 }, setup, bodies } Everything around it is the same as a Rapier 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 matter-js matter.js is an optional peer dependency, like Rapier. A room installs the engine it uses.
When to pick it
Pick matter.js when the game is flat and stays flat: a top-down arena, a side-scroller, a pinball table, a physics puzzle.
The concrete reason is not “2D is simpler”. It is that the documented way to hold a 3D engine in a plane has a trap in it. Locking out-of-plane translation together with both out-of-plane rotations removes friction entirely from box-shaped bodies. Crates slide forever and nothing stacks, and the workaround is to leave one rotation free and remember why. A real 2D engine makes the whole recipe unnecessary: there are no axes to lock.
Pick Rapier when you want client-side prediction, when the game has depth, or when you want the stronger determinism story. Prediction is the sharpest difference, and it is the next section.
What it does not do: prediction
There is no client-side prediction for matter2d in this release. A client joining a matter2d room
does not pass physics to joinRoom, and body fields arrive as ordinary interpolated state.
That is exactly what a non-predicting physics client already did, so the draw loop reads room.render and nothing else changes.
The numbers this decision was taken from, measured on the build that ships it:
| measurement | result |
|---|---|
| Same process, same seed, same inputs: 50 bodies, 300 ticks, 5 runs | bit-identical, zero drift |
| Across a save and restore: save at tick 150, replay to 300 | 0.24 units of drift on a 1400-unit board (0.017%) |
| Step cost against Rapier at 50 bodies, per tick | matter2d 0.211 ms, rapier3d 0.109 ms |
| Across two different builds of V8 | not measured |
Read those in order. matter.js repeats itself perfectly inside one process, so the engine is not random. A restored world is not bit-identical, and the reason is structural rather than incidental: matter.js has no world snapshot (see below), so a restore rebuilds the world and reapplies per-body state, and resting contacts do not survive that. And the measurement prediction actually rests on, the same world stepping identically in a browser’s V8 and the server’s, has not been taken. Until it is, prediction for matter2d would be a promise nobody has checked.
Two smaller findings from the same work, worth knowing before you tune anything:
- matter.js is not the cheap option. At equal body counts it costs about twice a Rapier step. It is still far inside a 60 Hz budget at fifty bodies, but “2D so it must be cheaper” is wrong.
- Use
tickRate: 60. matter.js’s integrator wants a step no larger than 16.667 ms and prints a console warning above it. A 20 Hz matter2d room works and is less accurate.
Units are matter.js’s
irtio blesses the engine rather than abstracting it, and that applies to its units too. Nothing here is translated:
- y points down. Gravity pulling downward is
{ x: 0, y: 1 }. gravityis a multiple of matter’s default, not metres per second squared. Its own default is{ x: 0, y: 1 }with an internal scale of 0.001.body.velocityis per step, not per second. That is matter’s own convention.- Distances are whatever you make them. Pixels are the usual choice.
Translating any of these would make every matter.js tutorial subtly wrong inside a room, which is the failure the “blessed, not abstracted” rule exists to avoid.
The room config
// irtio/world.ts
import type { Matter2dBodySpec, MatterEngine, MatterModule } from '@irtio/server';
export const gravity = { x: 0, y: 0 } as const; // top-down: none
export function setup(engine: MatterEngine, M: MatterModule): void {
M.Composite.add(engine.world, [
M.Bodies.rectangle(400, 20, 800, 40, { isStatic: true }),
M.Bodies.rectangle(400, 580, 800, 40, { isStatic: true }),
]);
}
export const bodies = {
runners: (M: MatterModule, _instance: unknown, id: string): Matter2dBodySpec => ({
body: M.Bodies.circle(0, 0, 14, { restitution: 0.4, frictionAir: 0.06, label: id }),
}),
}; setup builds static geometry once per world. bodies.<collection> turns one instance into a
body; matter.js has no separate collider concept, so a body is its geometry and there is no
second shape list to keep in step. A factory may also return constraints, which are added and
removed with the body.
The spawn pose comes from the schema, exactly as it does for Rapier: add({ x, y }) places the
body whatever the factory chose.
Reading the world in handlers: room.physics2d
tick(state, _dt, room) {
for (const [id, runner] of state.runners) {
const body = room.physics2d.body('runners', id);
if (!body) continue;
room.physics2d.matter.Body.applyForce(body, body.position, {
x: runner.ax * 0.0006,
y: runner.ay * 0.0006,
});
}
} room.physics2d gives you .matter (the namespace), .engine (the live Matter.Engine, whose .world is the composite), .timestep, and .body(collection, id).
It is a second accessor rather than a narrowing of room.physics, so that every existing Rapier
room keeps working untouched. Reading room.physics in a matter2d room throws and names room.physics2d, 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 matter2d room maps the ones a plane has:
| matter.js | channel |
|---|---|
position.x | x |
position.y | y |
angle | qz = sin(angle/2), qw = cos(angle/2) |
velocity.x / velocity.y | vx / vy |
angularVelocity | wz |
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(runner.qz, runner.qw); angleFrom2d, channelOf2d and applyChannel2d are the one place this mapping is written down,
and both sides of the wire use them. No wire format changed to add 2D.
Sleeping and waking
Rapier serializes its whole world, contacts included, so a Rapier wake is bit-exact. matter.js has
no world snapshot, so a matter2d room stores per-body state (position, angle, velocities, sleep
state, and which entity each body belongs to) and a wake rebuilds the world from your setup and
reapplies that state on top.
Two consequences worth designing around:
setupruns on every matter2d wake, not only on a rebuild. Keep it idempotent, which it is naturally if it only builds geometry.- Resting contacts and accumulated impulses are not restored. A settled pile may settle again with a small visible jolt. The measured drift over 150 ticks after a restore is at the top of this page.
The snapshot format did not change to accommodate any of this: the engine discriminant lives inside the existing format-2 physics section, and every blob written before matter.js existed is byte-identical and still reads.
The example
examples/matter-chase is a matter2d arena where two scripted NPCs hunt the
players. Everything on this page ships in it, and it is the room the 2D and NPC features were
tested against together.