Skip to content

BuiltinAdapter Quirks

Olivier Biot edited this page May 15, 2026 · 5 revisions

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.


1. applyForce ignores the application point

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 centroid

Portable: 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.

2. renderable.body is the same object the adapter manages

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);

3. Force accumulators reset at end-of-step

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 — observable

Portable: treat applyForce as fire-and-forget. Don't read the force back between calls — accumulate locally if you need to know what you applied.

4. def.density maps 1:1 to body.mass

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 = 3072

Portable: 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.

5. def.friction is a single scalar (static = kinetic)

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.

6. Collision callbacks fire inline during step() on Builtin

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, ...).

7. adapter.bodies is a mutable Set on Builtin only

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.

8. addBody throws on double-registration

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-registers

The 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.


See also

Clone this wiki locally