Skip to content

Migrating to the Physics Adapter API

Olivier Biot edited this page May 21, 2026 · 3 revisions

Migrating to the Physics Adapter API

melonJS 19.5 introduces the PhysicsAdapter interface — a portable physics layer that lets game code work on the built-in SAT physics (default), @melonjs/matter-adapter, or @melonjs/planck-adapter (faithful Box2D 2.3.0 port). This page covers migrating from the legacy new me.Body(this, shape) pattern to the new declarative bodyDef API, all on the built-in adapter. No engine swap, no behavioural change — just a code-style migration that prepares your game to switch engines later if you want to.

The legacy API still works on the built-in adapter and is not slated for removal. You only need to migrate if:

  • You plan to switch to @melonjs/matter-adapter or @melonjs/planck-adapter (or a future Rapier adapter)
  • You want your game code to be portable across adapters
  • You want to use the new collision lifecycle hooks (onCollisionStart / onCollisionActive / onCollisionEnd) — note these are not drop-in replacements for the legacy onCollision; they have an intentionally different contract (see the section below)

If none of those apply, your existing code keeps working unchanged.

Body construction

Before — legacy Body API

import { Body, collision, Rect, Sprite } from "melonjs";

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

        this.body = new Body(this, new Rect(0, 0, 64, 96));
        this.body.setMaxVelocity(3, 15);
        this.body.setFriction(0.4, 0);
        this.body.collisionType = collision.types.PLAYER_OBJECT;
        this.body.collisionMask =
            collision.types.WORLD_SHAPE | collision.types.ENEMY_OBJECT;
    }
}

After — declarative bodyDef

import { collision, Rect, Sprite } from "melonjs";

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

        // declarative body — auto-registered with the active adapter when
        // the renderable is added to a container (Container.addChild).
        this.bodyDef = {
            type: "dynamic",
            shapes: [new Rect(0, 0, 64, 96)],
            collisionType: collision.types.PLAYER_OBJECT,
            collisionMask:
                collision.types.WORLD_SHAPE | collision.types.ENEMY_OBJECT,
            maxVelocity: { x: 3, y: 15 },
            frictionAir: { x: 0.4, y: 0 },
        };
    }
}

A few things to notice:

  • Body no longer needs to be imported.
  • All body configuration lives in one declarative object, not spread across post-construction setters.
  • The body is constructed by the adapter when the renderable is added to a container, so this.body becomes available after Container.addChild runs (typically inside onActivateEvent or the next frame).

Setting velocity and forces

Before

update(dt) {
    if (input.isKeyPressed("left")) {
        this.body.force.x = -this.body.maxVel.x;
    } else if (input.isKeyPressed("right")) {
        this.body.force.x = this.body.maxVel.x;
    }
    if (input.isKeyPressed("jump") && !this.body.jumping) {
        this.body.vel.y = -this.body.maxVel.y;
        this.body.jumping = true;
    }
    return super.update(dt);
}

After

update(dt) {
    const vel = this.body.getVelocity();
    if (input.isKeyPressed("left")) {
        this.body.applyForce(-this.body.maxVel.x, 0);
    } else if (input.isKeyPressed("right")) {
        this.body.applyForce(this.body.maxVel.x, 0);
    }
    if (input.isKeyPressed("jump") && !this.body.jumping) {
        this.body.setVelocity(vel.x, -this.body.maxVel.y);
        this.body.jumping = true;
    }
    return super.update(dt);
}

Direct body.vel.x = ... and body.force.x = ... mutation still works on the built-in adapter (backward compatibility). The body methods (setVelocity, applyForce) are equivalent and portable — under @melonjs/matter-adapter, the same calls delegate to Matter.Body.setVelocity and Matter.Body.applyForce for you.

Note that applyForce is accumulating (each call adds to the per-step force), matching matter's semantics. Multiple applyForce calls in the same frame stack; the engine clears the accumulator at end-of-step. If you want set-and-replace semantics, body.force.set(x, y) is still available on the built-in adapter — but it's not portable.

Collision handlers

Before

onCollision(response, other) {
    if (other.body.collisionType === collision.types.ENEMY_OBJECT) {
        if (response.overlapV.y > 0 && !this.body.falling) {
            // stomp
            this.body.vel.y = -8;
            return false;   // skip the SAT push-out so we don't bump back
        }
        this.hurt();
    }
    return false;
}

After

// onCollisionStart fires exactly once when contact begins — fits a stomp /
// pickup / trigger-entry event. The legacy `onCollision` still works on
// every adapter and fires every frame the bodies overlap; pick whichever
// cadence fits your handler logic.
onCollisionStart(response, other) {
    if (other.body.collisionType === collision.types.ENEMY_OBJECT) {
        const vel = this.body.getVelocity();
        if (vel.y > 0) {
            // I was falling at the moment of impact — stomp
            this.body.setVelocity(vel.x, -8);
            return;
        }
        this.hurt();
    }
}

onCollision and onCollisionActive are NOT drop-in equivalents

This is the most important migration nuance on the page. The legacy onCollision handler is kept unchanged for backward compatibility — it still works exactly like it did in 19.4 — and the new onCollisionStart / onCollisionActive / onCollisionEnd lifecycle handlers introduced in 19.5 have an intentionally different contract. They live side by side; you can use either.

onCollision (legacy, unchanged) onCollisionStart / onCollisionActive / onCollisionEnd (new)
Cadence for dynamic-dynamic pairs 2× per frame per side 1× per frame per side (deduplicated)
response.a semantics Fixed per pair (first body in detector call) Always the receiver (response.a === this)
response.b semantics Fixed per pair Always the partner (response.b === other)
response.overlapN / response.overlapV "from a → b" (SAT legacy convention) Flipped per receiver so the convention is "from me → other"
response.normal ✓ — MTV of receiver. normal.y < -0.7 means "push me up" ⇒ I'm on top of other
response.depth ✓ — penetration depth (matter-native name; aliases overlap semantically)
return false to skip the SAT push-out ✓ (legacy idiom honored) ✗ — use bodyDef.isSensor: true or setSensor(true) instead
Engine portability Builtin only (matter ignores the return value) Identical contract on every adapter

Migration guidance:

  • Staying on onCollision is fine if you don't want any behavior change — that's the entire point of keeping it. New code in 19.5 can absolutely use onCollision.
  • Move to onCollisionStart / onCollisionActive when you want the cleaner contract: simpler stomp idiom (response.normal.y < -0.7), no dispatch ordering surprises, and code that runs identically on every adapter.

Supersedes rule (per renderable): if a renderable defines onCollisionActive, the legacy onCollision is not dispatched on that renderable — they are the same every-frame handler in two API styles, and dispatching both for the same overlap with different response shapes would be a footgun. Per-side, so renderable A can be on the modern handler while renderable B is on the legacy one in the same pair; each gets the contract it asked for.

Push-out default (matter-aligned): with no onCollision defined, push-out happens by default for dynamic non-sensor bodies on both adapters. Migrating from onCollision to onCollisionActive does not silently make your bodies pass through each other — solid stays solid. To opt out of push-out, mark the body as a sensor (bodyDef.isSensor: true or body.setSensor(true)). The pre-19.5 "no onCollision defined ⇒ implicit sensor" quirk is gone; the explicit flag replaces it.

Other changes in the example above

  • Velocity check (vel.y > 0) replaces response.overlapV.y > 0. The pre-contact velocity signal is identical on every adapter; the response object's shape under onCollision is engine-specific (SAT exposes overlapV / overlapN, matter exposes normal / depth / pair — see Switching Physics Adapters).
  • return false from onCollision to skip the SAT push-out for one-way platforms or triggers: the portable equivalent is bodyDef.isSensor: true (or body.setSensor(true) at runtime). Trigger and Collectable already declare themselves as sensors in 19.5+.

API equivalence reference

Legacy (≤19.4) Adapter API (19.5+) Notes
this.body = new Body(this, shape) this.bodyDef = { type: "dynamic", shapes: [shape], … } Auto-registered on Container.addChild
body.setMaxVelocity(x, y) bodyDef.maxVelocity: { x, y }
body.setFriction(x, y) bodyDef.frictionAir: { x, y } Renamed to match matter; scalar form also accepted
body.setStatic(true) bodyDef.type: "static" body.setStatic(true) still works at runtime
body.bounce = X bodyDef.restitution: X Renamed to match matter
body.mass = X bodyDef.density: X Built-in: 1:1 with mass. Matter: density × shape area. See BuiltinAdapter Quirks #4
body.gravityScale = X bodyDef.gravityScale: X
body.ignoreGravity = true bodyDef.gravityScale: 0 Same effect, portable
body.collisionType = X bodyDef.collisionType: X
body.collisionMask = X bodyDef.collisionMask: X
body.vel.set(x, y) body.setVelocity(x, y) Direct body.vel.x = … still works on built-in
body.force.x = X; body.force.y = Y body.applyForce(x, y) Accumulates per step; matches matter's semantics. body.force.set(...) still works on built-in
body.isSensor = true body.setSensor(true) or bodyDef.isSensor: true
onCollision(response, other) (still onCollision) or onCollisionStart / onCollisionActive — see callback section; these are NOT drop-in equivalents Legacy onCollision kept unchanged; new handlers have a different contract
return false from onCollision bodyDef.isSensor: true Only needed when migrating away from onCollision. The legacy handler still honors return false on Builtin.

Why bother?

If you're staying on the built-in adapter, the only practical benefit of migrating is access to the new collision lifecycle hooks (onCollisionStart, onCollisionEnd) and the body method API (body.setVelocity, body.applyForce, etc.). The legacy patterns continue to work indefinitely.

The real motivation is forward portability. The adapter-API form is the same on every physics engine. If you ever want to try @melonjs/matter-adapter or @melonjs/planck-adapter for a specific feature — constraints / joints, sleeping bodies, continuous collision detection, native rotational dynamics — the migration is a single line in new Application(…) rather than a full code rewrite.

See also

Clone this wiki locally