-
-
Notifications
You must be signed in to change notification settings - Fork 663
BuiltinAdapter Quirks
The default BuiltinAdapter wraps the legacy SAT physics that has shipped with melonJS for years. Existing game code that uses me.Body, the body.vel / body.force Vector2d fields, the SAT response object, and the world-level addBody helper continues to work unchanged — that's the whole point of "Builtin is the default."
But some of those behaviors are SAT-specific and will not carry across to a third-party adapter (@melonjs/matter-adapter, a future Box2D / Rapier adapter, or your own). This page lists the leaks so you can avoid baking them into code you might later port.
If your game is going to live and die on Builtin, ignore everything here. If you might switch engines, prefer the patterns on the right.
SAT bodies have no rotational dynamics — every force is accumulated at the centre of mass. The PhysicsAdapter interface accepts an optional application-point argument specifically so engines like Matter and Box2D can implement torque, but BuiltinAdapter drops it on the floor.
// Builtin: applies a force at the centre, regardless of `point`
adapter.applyForce(body, { x: 0.1, y: 0 }, { x: 32, y: 0 });
// Matter: applies torque because the point is offset from the centroidPortable: if you want torque, check adapter.capabilities.torque (or whatever it ends up being named) before relying on the offset point. Most platformer-style game code does not need this and can keep ignoring the third argument.
Under Builtin, renderable.body is a me.Body instance — the legacy class with public vel, force, friction, maxVel Vector2d fields. Direct mutation works:
this.body.vel.x = 5;
this.body.isStatic = true;
this.body.force.y -= 10;Under @melonjs/matter-adapter, renderable.body is a Matter.Body with completely different field names (velocity instead of vel, no public force accumulator, etc.). Code that pokes at body.vel.x directly will be reading undefined.
Portable: use the body-level helper methods we added in 19.5 — they work the same on every adapter:
const vel = this.body.getVelocity();
this.body.setVelocity(vel.x + 5, vel.y);
this.body.setStatic(true);
this.body.applyForce(0, -10); // accumulates, matches matter's semantics
this.body.setSensor(true);BuiltinAdapter does body.force.set(0, 0) after each step, so anything you add to body.force between steps is observable until the next step begins. Matter tracks forces internally and does not expose a user-visible accumulator at all.
// Builtin: this read works
this.body.force.set(0, -10);
console.log(this.body.force.y); // -10 — observablePortable: treat applyForce as fire-and-forget. Don't read the force back between calls — accumulate locally if you need to know what you applied.
On Builtin, bodyDef.density: 0.5 sets body.mass = 0.5 directly — no shape-area multiplication. Matter and Box2D compute mass = density × area, so the same density: 0.5 produces wildly different mass depending on the shape's size.
this.bodyDef = {
type: "dynamic",
shapes: [new Rect(0, 0, 64, 96)],
density: 0.5,
};
// Builtin: body.mass === 0.5
// Matter: body.mass ≈ 0.5 × 6144 = 3072Portable: if mass matters for your game feel, set mass directly (where supported) or tune density per adapter. Don't assume density: 0.5 "feels" the same across engines.
Builtin's Body.setFriction(x, y) sets per-axis friction, but only one number per axis — there is no static-vs-kinetic distinction. Box2D distinguishes them; if you ever expose a frictionStatic field on BodyDefinition, only Box2D will honor it.
Portable: stick to one friction value if you might port. Surface-feel differences across adapters are tuning territory, not API territory.
On Builtin, adapter.step(dt) runs the SAT detector inline, and onCollision / onCollisionStart / onCollisionActive / onCollisionEnd fire while the step is still in progress. Matter dispatches collision events after its world step completes.
If your callback re-enters the adapter (e.g. removing a body, applying an impulse to a third party, spawning a new body), the order of those side-effects relative to the rest of the step differs between adapters.
Portable: keep collision handlers side-effect-light when you can. For destructive operations (remove the body, fire a particle emitter, play a sound), defer to the next step via a queue or event.once(GAME_AFTER_UPDATE, ...).
The legacy world bridge exposes world.bodies as a JS Set you can add / delete / clear directly:
// Builtin only
world.bodies.add(renderable.body);
world.bodies.delete(renderable.body);Other adapters return a frozen empty Set from world.bodies so direct mutation throws a TypeError instead of silently no-op'ing. The portable path is to use the adapter's lifecycle methods:
// Portable
adapter.addBody(renderable, bodyDef);
adapter.removeBody(renderable);In practice you usually don't call either yourself — Container.addChild / removeChild auto-register declared bodyDefs with whichever adapter is active.
adapter.addBody(renderable, def) is a one-time operation. If a renderable already has a body managed by this adapter (e.g. because Container.addChild already auto-registered it from bodyDef), calling addBody again is a programming error and throws. This isn't a quirk to "fix" — it's the rule that documents the contract: one registration path per body.
// Wrong — auto-registration happened on addChild already
container.addChild(renderable);
adapter.addBody(renderable, renderable.bodyDef); // throws
// Right — pick one
renderable.bodyDef = { ... };
container.addChild(renderable); // auto-registersThe legacy bridge path (new Body(r, shape) first, then adapter.addBody) is allowed exactly once for backward compatibility, but new code should use bodyDef + addChild.
-
Switching Physics Adapters — the portable
PhysicsAdapterinterface and the recipes that work on every adapter. -
@melonjs/matter-adapterREADME — matter-specific features and porting notes.