Skip to content

BuiltinAdapter Quirks

Olivier Biot edited this page May 15, 2026 · 3 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.

9. Legacy onCollision fires 2× per frame for dynamic-dynamic pairs

The SAT detector iterates every non-static body in its outer loop. For a pair where both bodies are dynamic, the loop visits the pair twice — once with A as the outer body, once with B — and each visit dispatches onCollision to both sides. Net: each body's onCollision fires twice per frame for that pair, with response.a taking different values across the two calls.

class Player extends Renderable {
    onCollision(response, other) {
        // Will fire TWICE per frame when overlapping another dynamic body:
        //   call 1: response.a === player, response.b === enemy
        //   call 2: response.a === enemy,  response.b === player
        // The legacy idiom to dedupe is:
        if (this !== response.a) return;  // "I'm only the `b` side this call"
        // ...handle the contact once...
    }
}

This is the 19.4 behavior and we kept it bit-for-bit on onCollision for backward compatibility. The modern onCollisionActive is dedup'd to 1× per pair per side per frame on both adapters (and via the supersedes rule, defining it suppresses legacy onCollision on the same renderable). Migrate to onCollisionActive to drop the response.a === this check.

Matter has no equivalent — its engine emits each pair once per step.

10. Dynamic-dynamic collision is position-based, not Newtonian

When two dynamic bodies collide under default SAT push-out, separation does happen — Body.respondToCollision is called on each side with a mass-proportional ratio (other.mass / total_mass) — but the velocity response is per-body cancellation only. The outer-loop iterates each non-static body once, so push-out runs twice per pair, and the resulting end-state is non-Newtonian:

// Two equal-mass dynamic bodies (mass = 1 each). A moves into B with vel.x = 5.
// Frame:                A.x    A.vel.x   B.x   B.vel.x
//  initial:             100      5       124      0
//  after step 1:        105      2.5     124      0     ← B never moves; A's vel halved
//  after step 2:        107.5    1.25    124      0
//  after step 3:        108.75   0.625   124      0
//  ...                                                  (asymptotic approach, B stays put)
// Heavy (mass=10) hits light (mass=1):
// Position-wise, light is shoved aside by ~10× more than heavy — mass ratio honored.
// But light's velocity stays at 0 — momentum is NOT transferred. Light gets nudged
// spatially; it won't fly off after the contact ends.

What does work:

  • Mass-proportional positional push-out — light bodies move more, heavy bodies move less in the asymmetric case.
  • Per-body velocity cancellation along the contact normalBody.respondToCollision zeroes the normal-component of the receiver's velocity (and reflects it via bounce if body.bounce > 0).
  • bodyDef.isSensor: true — sensor bodies skip push-out entirely, only fire events.

What doesn't work (i.e. is NOT modeled):

  • Momentum / velocity transfer between dynamic bodies. A moving body hitting a stationary one will stop, but the stationary body won't gain velocity. No elastic collisions, no billiard-ball cue strike, no chain reactions.
  • Bouncy elastic response between two dynamic bodies. Setting restitution > 0 on both does affect the cancellation magnitude on each side, but doesn't reverse the moving body's velocity in a Newtonian way — vel drifts toward 0 quickly rather than bouncing back.
  • Equal-mass clean separation. The asymptotic decay (see table above) is geometrically correct push-out, but for game feel it looks like the moving body is stuck on a soft wall.

Real games target Builtin by keeping the collision graph dynamic-vs-static: player + enemies = dynamic; world geometry (floors, walls, platforms) = static. Any pair with at least one static body separates cleanly because there's only one outer iteration that matters. Dyn-dyn pairs (e.g. enemy bumping into enemy) work in the spatial sense — they don't tunnel — but won't produce realistic post-collision motion.

For Newtonian dyn-dyn (billiards, dominoes, projectile knockback, ragdoll-style chains), use @melonjs/matter-adapter — matter's solver runs proper constraint resolution and produces correct momentum transfer.


See also

Clone this wiki locally