Skip to content

Switching Physics Adapters

Olivier Biot edited this page May 14, 2026 · 2 revisions

Switching Physics Adapters

melonJS 19.5+ has a swappable physics layer. The built-in SAT physics is the default; alternative engines (@melonjs/matter-adapter, future Box2D adapter, custom adapters) plug in via the same PhysicsAdapter interface.

This page covers what stays the same, what changes by design, and the porting pitfalls to watch for.

Picking an adapter

import { Application, BuiltinAdapter, video } from "melonjs";
import { MatterAdapter } from "@melonjs/matter-adapter";

new Application(800, 600, {
    parent: "screen",
    renderer: video.AUTO,
    physic: new BuiltinAdapter(),          // default; same as omitting `physic`
    // physic: new MatterAdapter({ gravity: { x: 0, y: 5 } }),
});

Application accepts either an adapter instance or one of the built-in physic: "builtin" / "matter" shortcuts.

What stays the same

The PhysicsAdapter interface is the portable surface. Code written against these works on every adapter:

  • bodyDef (declarative body description on the renderable)
  • addBody / removeBody (auto-called by Container.addChild / removeChild)
  • getVelocity / setVelocity — read & write velocity
  • applyForce / applyImpulse — Newtonian force / impulse application
  • setPosition / setStatic / setGravityScale / setFrictionAir / setMaxVelocity
  • setCollisionType / setCollisionMask
  • setSensor — solid ⇄ pass-through toggle
  • isGrounded — currently in contact with a body below me
  • The four collision hooks on Renderable: onCollision, onCollisionStart, onCollisionActive, onCollisionEnd
  • The CollisionResponse object passed to those hooks (overlapV.y > 0 = A above B, regardless of underlying engine)

If your gameplay code only touches the list above, it's portable across adapters.

Optional / capability-gated APIs

These are typed as optional on the PhysicsAdapter interface. Check adapter.capabilities.<feature> or typeof adapter.<method> === "function" before calling:

Method Available on
setAngle(r, angle) matter (built-in is a no-op stub; SAT bodies don't rotate)
raycast(from, to) matter (built-in exposes equivalent via collision.rayCast top-level util)
queryAABB(rect) matter
getMaxVelocity(r) both

Behavioural differences (by design)

These differences are expected and stem from each engine's design — they're documented here so you can plan around them, not because either one is wrong.

Behaviour Builtin (SAT) Matter
applyForce units force.x is added directly to vel.x each frame (units = px/frame²) force / mass * dt² per step (Newtonian; small forces)
setVelocity for an impulse Immediate vel assignment Matter.Body.setVelocity — also resets positionPrev to keep Verlet coherent
Walking up a 45° slope Force-per-frame; even small WALK_FORCE climbs Newtonian; needs walk_force > mass * g * sin θ to overcome gravity-along-slope
onCollision returning false to opt out of solid response ✅ honored ❌ ignored — matter's solver has already resolved separation by the time the hook fires; use setSensor instead
Body rotation under torque ❌ ignored (SAT bodies don't rotate) ✅ unless fixedRotation: true (the matter-adapter default to match SAT)
One-way platforms return false from onCollision (SAT skips the response) setSensor(platform, true) + manual landing snap in onCollisionActive
Slope handling Custom slope-hack pattern (response.overlapV.y = abs(overlap) to force upward push) Geometric — slopes must be authored as proper ramps with no approach wall; alternatively snap-to-surface in onCollisionActive
Continuous collision detection ❌ — fast objects can tunnel ✅ matter has it
Sleeping bodies
Constraints (springs, joints) ✅ via raw Matter.Constraint (reach the engine via adapter.engine)

Porting pitfalls

isGrounded is literal, not predictive

It returns true whenever there's at least one active contact with a body whose center is below this one's. Inside an onCollisionStart handler for a stomp, the enemy you just landed on already counts as "ground" — so !isGrounded is not a reliable proxy for "I was airborne before this contact started."

Use the body's pre-contact velocity instead (vel.y > 0 = I was falling at the moment of contact).

Matter forces are much smaller than builtin forces

If you tuned WALK_FORCE = 0.4 on the builtin adapter and switch to matter, the player will rocket sideways across the screen because force/mass * dt² with dt ≈ 16 and a typical 64×96 sprite (mass ≈ 6) gives a velocity contribution of ~30 per step. Most matter platformers use forces in the 0.01–0.05 range. Start two orders of magnitude smaller and tune up.

applyForce on matter is not a "one-shot impulse"

It's a sustained Newtonian force. matter resets body.force to zero at the end of each step, so the force only acts for one step — but that one step's contribution is force / mass * dt², which depends on dt and the body's mass. For an instant velocity change (a jump, a dash, a knockback), use setVelocity or applyImpulse, not applyForce.

body.position is the centroid in matter; renderable.pos is top-left in melonJS

If your code reads body.position directly to put a UI marker over the body, the marker is offset by half the sprite's size. Either read renderable.pos (which the adapter keeps in sync), or compute body.position - posOffset where posOffset is half the sprite's extents.

Polyline bodies (zero-thickness lines) don't translate to matter

matter can't build a body from collinear vertices. The TMX object factory still creates a Line shape for <polyline> objects, but the matter adapter falls back to an axis-aligned bounding box (also zero-area, so effectively unusable). Replace polyline bodies with thin rectangles when using matter — or post-process at load time and call adapter.updateShape(child, [new Rect(0, 0, w, thickness)]).

response.overlapV has a sign convention you might not expect

Matches legacy melonJS SAT: overlapV.y > 0 means body a is above body b in canvas coordinates. matter's native pair.collision.normal is the opposite sign — the adapter negates it for you when synthesizing response, so portable handlers never see the sign flip.

Collision filter / mask: both names work in both adapters

// All four assignments are equivalent — use whichever matches your background:
body.collisionType = collision.types.PLAYER_OBJECT;          // melonJS legacy
body.collisionFilter.category = collision.types.PLAYER_OBJECT; // matter-native

body.collisionMask = collision.types.ENEMY_OBJECT;
body.collisionFilter.mask = collision.types.ENEMY_OBJECT;

In @melonjs/matter-adapter, these are live getters/setters over the same underlying state — they can't drift.

A simple porting example

A minimal player entity. The same class on the built-in adapter, then ported to @melonjs/matter-adapter. Numbered comments call out what changed and why.

Before — built-in (SAT) adapter

import { Application, collision, input, Rect, Sprite, video } from "melonjs";

new Application(800, 600, {
    parent: "screen",
    renderer: video.AUTO,
    // physic: defaults to BuiltinAdapter
});

const MAX_VEL_X = 3;
const MAX_VEL_Y = 15;

class Player extends Sprite {
    constructor(x: number, y: number) {
        super(x, y, { image: "player", framewidth: 64, frameheight: 96 });

        // declarative body — works on every adapter
        this.bodyDef = {
            type: "dynamic",
            shapes: [new Rect(0, 0, 64, 96)],
            collisionType: collision.types.PLAYER_OBJECT,
            maxVelocity: { x: MAX_VEL_X, y: MAX_VEL_Y },
            frictionAir: { x: 0.4, y: 0 },
        };

        input.bindKey(input.KEY.LEFT,  "left");
        input.bindKey(input.KEY.RIGHT, "right");
        input.bindKey(input.KEY.UP,    "jump", true);
    }

    update(dt: number) {
        const adapter = this.parentApp.world.adapter;
        const vel = adapter.getVelocity(this);

        // Horizontal: SAT integrates force.x directly into vel.x per frame.
        // A magnitude of MAX_VEL_X (=3) hits the velocity cap in one frame.
        if (input.isKeyPressed("left")) {
            adapter.applyForce(this, { x: -MAX_VEL_X, y: 0 });
        } else if (input.isKeyPressed("right")) {
            adapter.applyForce(this, { x:  MAX_VEL_X, y: 0 });
        }

        // Jump: edge-triggered. Set velocity directly upward.
        if (input.isKeyPressed("jump")) {
            adapter.setVelocity(this, { x: vel.x, y: -MAX_VEL_Y });
        }

        return super.update(dt);
    }

    onCollision(response, other) {
        if (other.body.collisionType !== collision.types.ENEMY_OBJECT) return true;
        // SAT idiom: "I came in from above" via overlapV.y > 0
        const adapter = this.parentApp.world.adapter;
        if (response.overlapV.y > 0 && !adapter.isGrounded(this)) {
            // stomp — bounce upward
            const vel = adapter.getVelocity(this);
            adapter.setVelocity(this, { x: vel.x, y: -MAX_VEL_Y * 0.75 });
            return false;
        }
        this.hurt();
        return false;
    }
}

After — @melonjs/matter-adapter

import { Application, collision, input, Rect, Sprite, video } from "melonjs";
import { MatterAdapter } from "@melonjs/matter-adapter";

new Application(800, 600, {
    parent: "screen",
    renderer: video.AUTO,
    // (1) Swap the adapter. Pick a gravity that feels right for your sprite scale —
    //     matter's native (0, 1) is moon-like at 32px-arcade scale; ~5 is closer to
    //     legacy SAT feel.
    physic: new MatterAdapter({ gravity: { x: 0, y: 5 } }),
});

const MAX_VEL_X = 3;
const MAX_VEL_Y = 15;

// (2) Matter forces are Newtonian (force/mass*dt²). Magnitudes scale ~100× smaller
//     than the SAT version — start here and tune.
const WALK_FORCE = 0.012;

class Player extends Sprite {
    constructor(x: number, y: number) {
        super(x, y, { image: "player", framewidth: 64, frameheight: 96 });

        // bodyDef is portable; same shape, same collision type, same maxVelocity cap.
        // (3) frictionAir is scalar in matter — averaging per-axis values would have
        //     surprised the Y axis with friction it didn't expect.
        this.bodyDef = {
            type: "dynamic",
            shapes: [new Rect(0, 0, 64, 96)],
            collisionType: collision.types.PLAYER_OBJECT,
            maxVelocity: { x: MAX_VEL_X, y: MAX_VEL_Y },
            frictionAir: 0.02,
        };

        input.bindKey(input.KEY.LEFT,  "left");
        input.bindKey(input.KEY.RIGHT, "right");
        input.bindKey(input.KEY.UP,    "jump", true);
    }

    update(dt: number) {
        const adapter = this.parentApp.world.adapter;
        const vel = adapter.getVelocity(this);

        // (4) Same applyForce calls — only the magnitude changed.
        if (input.isKeyPressed("left")) {
            adapter.applyForce(this, { x: -WALK_FORCE, y: 0 });
        } else if (input.isKeyPressed("right")) {
            adapter.applyForce(this, { x:  WALK_FORCE, y: 0 });
        }

        // (5) Jump: setVelocity is portable — instant velocity change works the same
        //     way on both adapters. No change needed.
        if (input.isKeyPressed("jump")) {
            adapter.setVelocity(this, { x: vel.x, y: -MAX_VEL_Y });
        }

        return super.update(dt);
    }

    // (6) Use onCollisionStart — fires exactly once per contact entry. The legacy
    //     `onCollision` alias still works on both adapters and fires every frame
    //     during contact; pick whichever cadence fits your handler logic.
    onCollisionStart(response, other) {
        if (other.body.collisionType !== collision.types.ENEMY_OBJECT) return;

        // (7) "Did I come in from above?" — replace `!isGrounded` (which flips to true
        //     the instant we land on the enemy) with the body's pre-contact velocity:
        //     vel.y > 0 means I was falling at the moment of impact. Position check
        //     guards against a falling side-swipe being counted as a stomp.
        const adapter = this.parentApp.world.adapter;
        const vel = adapter.getVelocity(this);
        if (vel.y > 0 && this.centerY < other.centerY) {
            adapter.setVelocity(this, { x: vel.x, y: -MAX_VEL_Y * 0.75 });
            return;
        }
        this.hurt();
        // (8) `return false` from `onCollision` to opt out of solid response is a SAT
        //     idiom — matter has already resolved the contact. Use
        //     adapter.setSensor(other, true) if you need non-solid behaviour.
    }
}

What changed, summarized

# Change Reason
1 Pass MatterAdapter to physic switching engines
2 WALK_FORCE = 0.012 (down from 3) matter forces are Newtonian — much smaller magnitudes
3 frictionAir: 0.02 (scalar, not {x, y}) matter has no per-axis air friction
4 applyForce call sites unchanged portable API
5 setVelocity for jump unchanged portable API; correct pattern for instant velocity change
6 onCollisiononCollisionStart one-shot semantics fit a stomp/hurt event better than every-frame
7 Replace !isGrounded with vel.y > 0 + centerY < other.centerY isGrounded flips to true the instant we land
8 Drop return false from collision handler matter ignores the return value (solver has already run)

bodyDef shape, collision masks, max-velocity cap, key bindings, sprite setup — all unchanged.

Recommended porting checklist

  1. Boot banner — verify your adapter's physic: <name> line appears in the console.
  2. Gravity — start with gravity: { x: 0, y: 4 } (matter-native default × ~4) for arcade-feel platformers; tune from there.
  3. Forces — divide your existing builtin applyForce magnitudes by ~30–100 as a starting point.
  4. fixedRotation — pass fixedRotation: true in any bodyDef that should stay axis-aligned (the matter adapter defaults this to true to match SAT, but it's explicit-is-better).
  5. One-way platforms / slopes — replace SAT-response hacks with setSensor + manual snap-to-surface in onCollisionActive.
  6. Stomp / "was I airborne" checks — replace !isGrounded with vel.y > 0 reads (the pre-contact velocity).
  7. TMX polylines — give them thickness at load time.
  8. Test — collision events fire as expected, no false hurts on stomp, no stuck-on-slope behaviour.

If you hit something that surprises you and isn't on this list, open an issue — it probably belongs here.

Clone this wiki locally