-
-
Notifications
You must be signed in to change notification settings - Fork 664
Switching Physics Adapters
melonJS 19.5+ has a swappable physics layer. The built-in SAT physics is the default; alternative engines (@melonjs/matter-adapter, @melonjs/planck-adapter — faithful Box2D 2.3.0 port, custom adapters) plug in via the same PhysicsAdapter interface.
Per-renderable collision dispatch is normalized across every adapter. Every Renderable receives onCollisionStart / onCollisionActive / onCollisionEnd callbacks with a (response, other) signature — you write the same handlers no matter which engine is running underneath, and the adapter routes pair-level events to the right renderables for you.
This page covers what stays the same, what changes by design, and the porting pitfalls to watch for.
Prerequisite: this page assumes you're already on the 19.5 adapter API (declarative
bodyDef, body method calls likebody.setVelocity(x, y)). If your code still uses the legacynew me.Body(this, shape)pattern with directbody.vel/body.forcemutation, migrate tobodyDeffirst — see Migrating to the Physics Adapter API. That migration is a one-time code-style refactor that stays on the built-in adapter; this page picks up from there to cover engine selection.
import { Application, BuiltinAdapter, video } from "melonjs";
import { MatterAdapter } from "@melonjs/matter-adapter";
import { PlanckAdapter } from "@melonjs/planck-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 } }),
// physic: new PlanckAdapter({ gravity: new Vector2d(0, 320) }), // px/s²
});Application accepts either an adapter instance or one of the built-in physic: "builtin" / "matter" / "planck" shortcuts.
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 byContainer.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
(response, other)signature of those hooks. The modern handlers (onCollisionStart/onCollisionActive/onCollisionEnd) share an identical core shape across every adapter ({ a, b, normal, depth, pair? }) — see Response shape below. The legacyonCollisionhandler is built-in-only and uses the SAT-shaped response.
If your gameplay code only touches the list above, it's portable across adapters.
raycast(from, to) and queryAABB(rect) are portable across every adapter as of 19.5 — raycast is capability-gated via adapter.capabilities.raycasts (all three official adapters return true), and queryAABB is mandatory on the PhysicsAdapter interface. Both return the same shapes regardless of engine (RaycastHit = { renderable, point, normal, fraction }, queryAABB → Renderable[]).
The remaining capability-gated methods:
| Method | Available on | Notes |
|---|---|---|
setAngle(r, angle) |
matter, planck | builtin is a no-op stub — SAT bodies don't rotate |
getMaxVelocity(r) |
every adapter |
capabilities.velocityLimit is true everywhere |
For optional features without a dedicated method, check the matching capabilities.<feature> flag:
| Capability | builtin | matter | planck |
|---|---|---|---|
constraints |
✗ | ✓ (Matter.Constraint) | ✓ (planck joints) |
continuousCollisionDetection |
✗ | ✓ | ✓ (per-body bullet flag) |
sleepingBodies |
✗ | ✓ | ✓ |
raycasts |
✓ | ✓ | ✓ |
velocityLimit |
✓ | ✓ | ✓ |
isGrounded |
✓ (flag-based, see Quirks) | ✓ (contact-based) | ✓ (contact-based) |
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.
Also: see BuiltinAdapter Quirks for the list of SAT-only behaviors that work today but won't carry to other engines — patterns to avoid baking into code that might switch adapters later.
| Behaviour | Builtin (SAT) | Matter | Planck (Box2D) |
|---|---|---|---|
setVelocity units |
px/step (added directly to pos each frame; dt-ignored) | px/step (matter's Verlet; dt-ignored) |
px/s (integrated by dt; converted to m/s internally) |
applyForce units |
force.x is added directly to vel.x each frame (units = px/frame²) |
force / mass * dt² per step (Newtonian; small forces) |
Newtonian; force is in (px·kg)/s², converted to m/s² via pixelsPerMeter
|
| 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 |
Same as matter |
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 |
❌ ignored — same reason |
| Body rotation under torque | ❌ ignored (SAT bodies don't rotate) | ✅ unless fixedRotation: true (matter-adapter default to match SAT) |
✅ unless fixedRotation: true (planck-adapter default to match SAT) |
| One-way platforms |
return false from onCollision (SAT skips the response) |
setSensor(platform, true) + manual landing snap in onCollisionActive
|
Same as matter |
| Slope handling | Custom slope-hack pattern (response.overlapV.y = abs(overlap) to force upward push) |
Geometric — slopes must be authored as proper ramps; alternatively snap-to-surface in onCollisionActive
|
Same as matter |
| Continuous collision detection | ❌ — fast objects can tunnel | ✅ matter has it | ✅ per-body bullet flag (Box2D's native CCD) |
| Sleeping bodies | ❌ | ✅ | ✅ |
| Constraints / joints | ❌ | ✅ via Matter.Constraint (reach via adapter.engine) |
✅ via planck's joint API (reach via adapter.world.createJoint(...)) |
isGrounded |
Flag-based — gravity flips body.falling each frame, may read TRUE in free fall and FALSE at rest. See Quirks. |
Contact-based — TRUE iff there's a contact pair below | Contact-based — TRUE iff there's a contact below |
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).
setVelocity reads in px/step on builtin and matter (the value is added directly to position each frame; dt is ignored). On planck it's px/second — the value is integrated over dt, so the same call moves the body ~60× more slowly at 60fps. A setVelocity(0, 60) jump:
- Builtin / matter: body moves 60 px in one step.
- Planck: body moves 60 px in one second (≈ 1 px / step at 60fps).
When porting a builtin or matter game to planck, multiply impulse velocities by your frame rate (typically 60). Force-driven motion auto-scales because planck's applyForce is also dt-integrated; only the impulse-style setVelocity / applyImpulse calls need adjustment.
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.
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.
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.
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)]).
The collision response shape depends on which handler family receives it:
Modern handlers (onCollisionStart / onCollisionActive / onCollisionEnd) — same contract on every adapter:
{
a: Renderable, // always === this (the receiver)
b: Renderable, // always === other (the partner)
normal: { x: number, y: number }, // unit MTV for the receiver
depth: number, // penetration depth (always positive)
pair?: unknown, // engine-native pair (matter only — Matter.Pair)
// builtin-only @deprecated SAT extras, flipped per receiver:
overlap?: number,
overlapN?: { x, y },
overlapV?: { x, y },
}The portable stomp idiom is response.normal.y < -0.7 ("push me up to escape" ⇒ I'm on top of b). Same expression on every adapter. response.pair shape is adapter-specific (Matter.Pair under @melonjs/matter-adapter; future Rapier / Box2D adapters would expose their own ContactPair / b2Contact).
Legacy onCollision handler — built-in SAT contract, unchanged from 19.4:
{
a, b, // fixed per pair (NOT receiver-symmetric)
overlap, overlapN, overlapV, // SAT legacy fields, "from b → a" sign
aInB, bInA,
indexShapeA, indexShapeB,
}The legacy stomp idiom is response.overlapV.y > 0 && response.a === this (the response.a === this check is necessary because a/b are fixed per pair and the same handler fires once for each iteration order — see the BuiltinAdapter Quirks page).
onCollision is only meaningful under the built-in adapter; @melonjs/matter-adapter does not synthesize SAT-shape fields, so legacy onCollision is best treated as builtin-only.
For "did I land on this?" / "was I falling at the moment of impact?" — both work, but if you want a single idiom that runs unchanged on every adapter and survives mid-tick velocity mutations:
onCollisionStart(_response, _other) {
if (this.body.getVelocity().y > 0) {
// I was falling at impact — same signal on every adapter
}
}Use the contact normal for direction-of-contact reads; use velocity for "what was I doing right before this contact" reads.
// 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.
Concrete patterns for common gameplay needs. Each recipe is labelled Portable (same renderable code under any adapter), Portable via velocity (same code, just route through the body's velocity rather than the contact response), or Matter-only (uses a feature gated by adapter.capabilities).
setVelocity is the canonical "impulse" pattern on every adapter. Direct mutation of vel.y works under the builtin adapter but not under matter (matter's Verlet integrator needs both velocity and positionPrev reset together); the body method handles that for you.
const vel = this.body.getVelocity();
this.body.setVelocity(vel.x, -JUMP_VEL); // preserves horizontal motionMark the body as a sensor — collisions still fire onCollisionStart but the solver doesn't physically push the player away.
class Coin extends Sprite {
constructor(x, y) {
super(x, y, { image: "coin" });
this.bodyDef = {
type: "static",
shapes: [new Ellipse(16, 16, 32, 32)],
isSensor: true,
collisionType: collision.types.COLLECTABLE_OBJECT,
collisionMask: collision.types.PLAYER_OBJECT,
};
}
onCollisionStart(_response, _other) {
gameState.score += 100;
this.ancestor.removeChild(this);
}
}A sensor body + manual snap-to-top from the player. Falling players land; jumping players pass through; pressing down drops through.
// Platform definition
this.bodyDef = {
type: "static",
shapes: [new Rect(0, 0, width, height)],
isSensor: true, // <-- key: matter doesn't try to resolve the contact
collisionType: collision.types.WORLD_SHAPE,
collisionMask: collision.types.PLAYER_OBJECT,
};
// Player handler — same on both adapters
onCollisionActive(_response, other) {
if (other.type !== "platform") return;
if (input.keyStatus("down")) return; // drop-through
const vel = this.body.getVelocity();
if (vel.y < 0) return; // jumping up — pass through
const playerBottom = this.pos.y + this.height;
const platformTop = other.pos.y;
if (playerBottom - platformTop > this.height * 0.5) return; // came from below
const adapter = this.parentApp.world.adapter;
adapter.setPosition(this, scratchPos.set(this.pos.x, platformTop - this.height));
this.body.setVelocity(vel.x, 0);
}Read the body's pre-contact velocity in onCollisionStart. The signal is identical on every adapter and survives mid-tick mutations that contact normals can't.
onCollisionStart(_response, other) {
if (other.body.collisionType !== collision.types.ENEMY_OBJECT) return;
const vel = this.body.getVelocity();
if (vel.y > 0) {
// I was falling at the moment of impact — stomp
other.die();
this.body.setVelocity(vel.x, -STOMP_BOUNCE);
} else {
this.hurt();
}
}If you do want adapter-native contact info (slope normals, penetration depth) you can branch on adapter.name === "@melonjs/matter-adapter" and reach response.normal / response.depth / response.pair — but that handler is no longer portable.
Both matter and planck expose capabilities.constraints: true. Reach the engine through the adapter's escape hatch — no need to add the underlying physics library as a direct dependency.
Matter — Matter.Constraint:
const adapter = app.world.adapter as MatterAdapter;
if (adapter.capabilities.constraints) {
const spring = adapter.matter.Constraint.create({
bodyA: anchor.body,
bodyB: player.body,
stiffness: 0.04, // 0 = floppy rope, 1 = rigid rod
length: 80,
});
adapter.matter.Composite.add(adapter.engine.world, spring);
}Use stiffness: 1 for a rigid hinge, low stiffness (~0.01–0.05) for spring-like behaviour.
Planck — Box2D joints (faithful 2.3.0 surface):
import { DistanceJoint, RevoluteJoint } from "planck";
const adapter = app.world.adapter as PlanckAdapter;
if (adapter.capabilities.constraints) {
// Distance joint = spring; tune frequencyHz + dampingRatio.
const spring = adapter.world.createJoint(new DistanceJoint({
bodyA: anchor.body,
bodyB: player.body,
length: adapter.px2m(80),
frequencyHz: 4,
dampingRatio: 0.5,
}));
// Revolute joint = hinge / pivot:
// const hinge = adapter.world.createJoint(new RevoluteJoint({ ... }));
}Both adapters' constraint solvers run inside their own step() — no extra integration code is needed in your update loop.
Both adapters can mark idle bodies as "sleeping" and skip integrating them entirely until disturbed — a meaningful CPU win when you have dozens of static-after-settling props (debris, fallen blocks, settled stacks).
Matter — enable at engine construction:
new MatterAdapter({
gravity: { x: 0, y: 5 },
matterEngineOptions: {
enableSleeping: true,
},
});
// Wake a specific body programmatically (e.g. on a trigger event):
const adapter = app.world.adapter as MatterAdapter;
if (adapter.capabilities.sleepingBodies) {
adapter.matter.Sleeping.set(this.body, false);
}Planck — sleeping is on per-body by default; just toggle the world flag if needed:
const adapter = app.world.adapter as PlanckAdapter;
adapter.world.setAllowSleeping(true); // default — bodies sleep when idle
// Wake a specific body:
(this.body as planck.Body).setAwake(true);The builtin adapter has no equivalent — there's no integration cost to skip, since SAT only runs collisions, not Verlet integration.
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.
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;
}
}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.
}
}| # | 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 |
onCollision → onCollisionStart
|
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.
-
Boot banner — verify your adapter's
physic: <name>line appears in the console. -
Gravity — start with
gravity: { x: 0, y: 4 }(matter-native default × ~4) for arcade-feel platformers; tune from there. -
Forces — divide your existing builtin
applyForcemagnitudes by ~30–100 as a starting point. -
fixedRotation— passfixedRotation: truein anybodyDefthat should stay axis-aligned (the matter adapter defaults this totrueto match SAT, but it's explicit-is-better). -
One-way platforms / slopes — replace SAT-response hacks with
setSensor+ manual snap-to-surface inonCollisionActive. -
Stomp / "was I airborne" checks — replace
!isGroundedwithvel.y > 0reads (the pre-contact velocity). - TMX polylines — give them thickness at load time.
- 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.