2D rooms with matter.js
Use matter.js for 2D physics in JavaScript. The room advances the engine each tick and synchronizes body state. Add client prediction through physics2d for immediate local input. See Rapier 2D for a WASM-based alternative.
physics: { engine: 'matter2d', gravity: { x: 0, y: 1 }, setup, bodies } 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 you already have a matter2d room, or when you want the engine itself to be plain JavaScript: matter.js is a normal npm package with no WASM to fetch and instantiate, which both Rapier builds have.
Pick rapier3d when the game has depth. All three engines predict on the client.
Prediction, and the one thing it needs here
A matter2d room predicts through joinRoom({ physics2d }), the sibling of Rapier’s physics. It
takes the same shared world-builder exports the room config uses (gravity, setup, bodies, intents) plus module, the engine imported from @irtio/client/matter, the loop behind it is
the same one, and room.prediction reads identically. See client-side prediction for the whole contract.
The one addition is settle: a per-step force applied to every local body of a collection,
owned or not, run after the intent pass.
import matter from '@irtio/client/matter';
joinRoom(schema, {
physics2d: {
module: matter,
gravity: { x: 0, y: 0 },
// 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.
timestep: 1 / 60,
bodies: world.bodies,
intents: { players: world.intents.players },
settle: { crates: world.settleCrate },
},
}); It exists because matter2d rooms usually apply gravity per body rather than through engine.gravity: an owned character gets its gravity inside its own intent hook, and a crate
nobody owns has no hook at all. Without settle that crate hangs in the air for the length of the
prediction lead and is snapped back by every correction. Rapier has no equivalent, because engine
gravity does that job there.
settle runs over the bodies this client simulates and no others. A body it does not simulate,
past the prediction cap or in a collection that is not predicted, gets a kinematic proxy
instead, which on matter is a static body driven each step to the pose the renderer draws, so a
per-step force on one would do nothing in any case. Body.setStatic rewrites friction and restitution, and the adapter puts the factory’s values back: a proxy is the server’s body, not a
stickier copy of it.
Two matter-specific details worth knowing if you write the hooks:
- A velocity is a displacement per sixtieth of a second, not per second and not per step:
matter normalises
body.velocityagainst its own base delta whatever the engine’s step is. The correction-suppression tolerance is measured against what one step of an error actually moves the body by, which at 60 Hz is the plainepsilon. - Read the determinism numbers below before you rely on this over a long session. They are the honest state of what has and has not been measured.
The determinism measurements
These were taken before prediction landed, and nothing about them has changed:
| 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.
The last row is the one to keep in mind. The same world stepping identically in a browser’s V8 and
the server’s has still not been measured directly, and it is what prediction rests on. What is
measured is the observable that matters in practice: the correction rate a predicting client
actually produces against a real server. A room that starts correcting far more often than it used
to is the signal that this row has stopped being harmless, and room.prediction.stats is where you
watch it.
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 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.