Physics and collision
melonJS has a built-in SAT collision world and two optional rigid-body adapters. Movement, collision callbacks and spatial queries all behave differently depending on which is active — and several of the differences fail silently.
Choosing a world
| when | |
|---|---|
| built-in (default) | Arcade-style: platformers, top-down games. Fast, position-based, not Newtonian. |
@melonjs/planck-adapter |
Full rigid-body dynamics — stacking, joints, realistic restitution. |
@melonjs/matter-adapter |
Same class of thing, backed by matter-js. |
An adapter is an Application setting, not a plugin:
import { PlanckAdapter } from "@melonjs/planck-adapter";
const app = new Application(800, 600, {
parent: "screen",
physic: new PlanckAdapter({
gravity: { x: 0, y: 320 }, // pixels/s², default {x: 0, y: 320}
pixelsPerMeter: 32, // default 32
subSteps: 2, // default 1
}),
});
await app.init();
PlanckAdapter also takes velocityIterations (default 8) and
positionIterations (default 3). MatterAdapter takes a different set —
gravity (default {x: 0, y: 1}, matter's own convention), subSteps and
matterEngineOptions; it has no pixelsPerMeter because it already works in
pixels. subSteps is the anti-tunnelling knob when per-frame motion exceeds a
body radius. Pick one adapter per game; physic: "none" disables physics
entirely — world.step skips the simulation and the world behaves as a pure
scene graph.
world.physic carries the active adapter's label ("builtin", "planck",
"matter", "none"), so game code can branch without importing the class.
Two ways to declare a body
bodyDef — declarative, portable, works on every adapter. Prefer it.
class Player extends Sprite {
constructor(x, y, settings) {
super(x, y, settings);
this.bodyDef = {
type: "dynamic",
shapes: [ /* … */ ],
collisionType: collision.types.PLAYER_OBJECT,
collisionMask: collision.types.WORLD_SHAPE | collision.types.ENEMY_OBJECT,
restitution: 0.2,
};
}
}
The engine forwards it to the active adapter when the renderable is added with
Container.addChild, and only if that container is already attached to the root
world; the broadphase picks the renderable up on the next world.update().
Calling adapter.addBody() yourself registers the body but leaves the
renderable out of the scene graph, so it integrates but never collides.
Other portable bodyDef fields: density, frictionAir (number or {x, y}),
friction, restitution, gravityScale, maxVelocity, fixedRotation,
isSensor, userData. Not every adapter honours every one — the built-in
adapter ignores friction, fixedRotation and userData, and maps
frictionAir onto its per-axis body.friction damping vector.
new Body(this, shape) — the built-in-native API. Fine, but ties the entity
to the built-in world.
Per-shape filtering and triggers
A shape in shapes may carry its own collisionType, collisionMask,
isTrigger (default false) and isActive (default true). A shape can only
narrow what its body allows — the body's type/mask are checked first.
const feet = new Rect(0, 24, 32, 8);
feet.collisionMask = collision.types.WORLD_SHAPE;
const torso = new Rect(0, 0, 32, 24);
torso.collisionMask = collision.types.ENEMY_OBJECT;
torso.isTrigger = true; // detects hits, never gets pushed by them
isActive: false removes the shape from the narrow phase and from raycasts
without shrinking the body's bounds. Honoured by the built-in and planck
adapters; the matter adapter filters per body and ignores them. Under planck the
body-wide setters (setCollisionType / setCollisionMask / setSensor) write
every fixture and so overwrite per-shape values.
For contacts reported per shape pair rather than per body pair, define
onShapeCollisionStart / onShapeCollisionActive / onShapeCollisionEnd.
Declaring at least one of them opts into shape-pair enumeration; declaring none
costs nothing.
Move with forces, not by assigning position
// ✗ the adapters overwrite this from the engine body every step
this.pos.x += this.speed;
// ✓ portable — works on every adapter
this.body.applyForce(thrustX, 0);
this.body.setVelocity(vx, vy);
// ✓ built-in only — `force` and `maxVel` are BuiltinAdapter Body fields
this.body.force.x = this.body.maxVel.x;
Under matter and planck, syncFromPhysics() copies the engine body's position
back onto renderable.pos after every step, so a direct write is simply erased
— no error. The built-in world integrates pos in place instead, so a write
survives but fights collision resolution and jitters. To teleport, go through
adapter.setPosition(renderable, p) so the engine body moves with it.
Collision callbacks — which one to use
The lifecycle is onCollisionStart (contact begins), onCollisionActive (every
step while it persists) and onCollisionEnd (contact breaks). None of them are
defined on Renderable — you just declare the ones you want and the dispatcher
checks typeof. The legacy onCollision still exists and has a trap:
On the built-in world,
onCollisionfires twice per frame for dynamic-dynamic pairs — once per outer-loop visit — and gets the raw SAT response with a fixeda/bper pair. On the adapters it is routed to their "still touching" phase and fires once per side.
The legacy dedupe idiom is if (this !== response.a) return;. Defining
onCollisionActive suppresses the legacy callback on that renderable (the
"supersedes" rule, applied per side so a and b can migrate independently),
and is deduped to once per pair per side per frame on every adapter.
Return values only work on the legacy handler. return false from
onCollision skips the built-in SAT push-out; what onCollisionStart /
onCollisionActive return is discarded. To opt a body out of the physical
response, set bodyDef.isSensor / body.setSensor(true), or make the shape a
trigger. With no onCollision defined at all, push-out happens by default for
dynamic non-sensor bodies.
The two handler families do not get the same response object:
onCollision (legacy) |
onCollisionStart / Active / End |
|
|---|---|---|
a / b |
fixed per pair | receiver-symmetric: a === this, b === other |
| overlap magnitude | response.overlap |
response.depth |
| contact axis | response.overlapN / overlapV |
response.normal |
| also carries | aInB, bInA, indexShapeA/B, isTriggerContact |
pair (adapter-native, absent on built-in) |
On the modern response overlap / overlapN / overlapV are still present but
@deprecated (and undefined under the matter adapter) — use depth and
normal. normal is the direction the receiver must move to separate, in
canvas coordinates: normal.y < -0.7 means "push me up", i.e. I landed on top
of other.
One portability wrinkle: the built-in adapter dispatches onCollisionEnd with
undefined as the response; the matter and planck adapters pass a real one.
Guard it.
Never mutate the world during contact dispatch
Collision callbacks are dispatched from inside the physics step on every
adapter, and each engine has its own tolerance for being mutated there. Flag the
work and drain it from your Stage.update:
onCollisionStart(response, other) {
this.pendingRemoval = true; // don't addChild / removeChildNow here
}
What actually happens per adapter:
- built-in — callbacks fire inline during
step().Container.removeChildis deferred, so it is safe;removeChildNow()anddestroy()are immediate, and the detector only survives them because it re-checksbody === undefinedafter every user callback. - planck —
onCollisionStart/onCollisionEndare dispatched from planck'sbegin-contact/end-contactevents, which fire while the world is locked. Adding a renderable with abodyDefthere makes planck'screateBodyreturnnulland the adapter then throws on the null handle.onCollisionActiveis dispatched after the step and is the safe one. - matter — callbacks come from matter's
collisionStart/collisionActive/collisionEndevents duringEngine.update.
Spatial queries
raycast and queryAABB are implemented by all three adapters:
import { Rect, Vector2d } from "melonjs";
// nearest hit only — returns null when nothing is hit
const hit = app.world.adapter.raycast(new Vector2d(x0, y0), new Vector2d(x1, y1));
// hit → { renderable, point, normal, fraction }
// takes a Rect, not a Bounds
const inArea = app.world.adapter.queryAABB(new Rect(x, y, w, h));
querySphere(centre, radius) (or querySphere(sphere)) is built-in only,
and only active under a 3D broadphase — i.e. world.sortOn === "depth", which
Camera3d sets. So is raycast3d. Check
world.adapter.capabilities.raycasts3d before calling either. raycast itself
is capability-gated by capabilities.raycasts.
The legacy collision.rayCast(line, result) is a different API: it takes a
Line, returns an array of every intersecting renderable, and only exists
on the built-in path.
Two prerequisites that produce empty results rather than errors:
isKinematicmust befalsefor a renderable to be in the broadphase at all (aBodysets this for you).- On the built-in adapter, the broadphase is cleared and rebuilt inside
world.update(), so querying before the first update returns nothing.
3D collision: Box3d and the Z pushback
The narrowphase is not 2D-only. Box3d is the one shape with a depth extent,
and a Box3d-vs-Box3d contact is resolved in three dimensions — it is the
only pair in the engine that can push back along Z.
import { Box3d } from "melonjs";
// centre offset from the body, then half-extents on each axis
rock.body.addShape(new Box3d(0, 0, 0, 40, 60, 40));
boat.body.addShape(new Box3d(0, 0, 0, 30, 40, 60));
Reach for this for anything moving in the XZ plane under a Camera3d — a
runner dodging obstacles, a 2.5D platformer, pickups on a course. Hand-rolling
a distance check there is the usual mistake, and it throws away the
penetration vector the response already carries.
Read the Z axis off the response, not overlapN. The MTV is a single
axis, so exactly one of overlapN.x, overlapN.y and overlapNZ is
non-zero:
onCollision(response, other) {
if (response.overlapNZ !== 0) {
// a depth-only contact: overlapN / overlapV are BOTH zero here
this.pos.z -= response.overlapZ;
}
return true;
}
That is deliberate: a collision resolved along Z leaves the 2D fields at zero,
so an existing 2D onCollision applies no push rather than a wrong one.
Mixed pairs degrade to 2D. Box3d against a Polygon, Rectangle,
RoundRect or Ellipse tests the box's XY footprint and treats the planar
shape as unbounded along Z. Only Box3d-vs-Box3d gives a depth result, so
give BOTH sides a Box3d when you want one.
This is the narrowphase and is independent of the 3D broadphase queries above
— querySphere / raycast3d need world.sortOn === "depth"; Box3d shapes
do not.
Bounds are what the broadphase sorts by, and they come from the SHAPE.
Every item is filed by its renderable's 2D getBounds(), so an object with no
extent cannot be placed and silently collides with nothing. A Mesh takes its
extent from its geometry and a GLTFModel from its part meshes, so both report
where they are without being told. A body's shapes do not contribute, so a
custom collidable built on a bare Container has no extent until you give it
one:
// a container reports an EMPTY bounds until it has a size of its own
group.resize(width, height);
enableChildBoundsUpdate also gives it one, but it re-measures the whole child
list on every update — reach for it only when the extent genuinely IS the
members' union and moves with them (a flock, a squad), not to give a fixed
group a size.
Built-in world quirks
These are specific to the default world and surprise people arriving from a real rigid-body engine:
- Dynamic-dynamic collision is position-based, not Newtonian. Separation is mass-proportional but the velocity response is per-body cancellation — two equal-mass bodies do not exchange momentum the way you would expect.
- Gravity defaults to
(0, 0.98)— pixels per frame², not m/s². Mutateapp.world.adapter.gravityat runtime, or passphysic: new BuiltinAdapter({ gravity })to override it.body.gravityScale(default1) scales it per body;0disables gravity for that body and is the portable replacement for the deprecatedbody.ignoreGravity. applyForce(x, y)is linear only unless you pass the optional(pointX, pointY)application point, which generates a torqueτ = r × Fintobody.angularVelocityviabody.pseudoInertia.applyTorque(τ)is the direct form. Note the rotation is visual: SAT collisions stay axis-aligned.- Force accumulators reset at end-of-step — for every body, including static, paused and off-screen ones — so forces must be applied every frame to persist.
isGroundedis flag-based, not contact-based — the adapter returns!body.falling && !body.jumping, flags updated by the last resolved collision, not a live contact test.def.densitymaps 1:1 tobody.mass(default1), anddef.frictionis ignored entirely.body.frictionis a per-axisVector2dof per-step velocity damping fed fromdef.frictionAir, not a surface coefficient.body.maxVeldefaults to(490, 490)and hard-clamps velocity after forces and friction. The canonical movement idiom isthis.body.force.x = this.body.maxVel.x.- Default
collisionTypeisENEMY_OBJECT, and defaultcollisionMaskisALL_OBJECT— set both explicitly. - Integration is gated on
inViewport || alwaysUpdate— an off-screen body stops simulating unless you setalwaysUpdate = true. It is also gated onupdateWhenPausedwhile the state manager is paused, and static bodies never integrate. addBodythrows on double-registration for a renderable that is already adapter-managed.
Porting between adapters
Same call, different magnitude — these are the ones that waste an afternoon:
- The portable API converts for you, the native handle does not.
adapter.setVelocity/body.setVelocity(x, y)are in pixels on every adapter; reaching for the raw handle (body.setLinearVelocityon planck) puts you in metres per second, scaled bypixelsPerMeter. - Time base differs. Built-in
body.velis pixels per frame; planck and matter integrate in seconds. The same number is ~60× off. - Force magnitudes are not comparable between adapters; re-tune rather than
reuse.
applyForceis a per-step accumulator everywhere (cleared each step, so call it every frame) —applyImpulseis the one-shot. Matter has no native impulse, so the adapter emulates it asΔv = J / massand ignores the application point. body.positionis the centroid in matter, whilerenderable.posis top-left in melonJS. The adapter stores a per-body offset sorenderable.posstays correct — but readbody.positiondirectly and you get the centroid.- Degenerate (zero-area / collinear) polygons — a Tiled polyline, a
Line— cannot be built by matter and fall back to their axis-aligned bounding box. Any other shape type throwsunsupported shape type. - Ellipses become circles on matter, using the average of the two radii.
isGroundedis capability-gated (capabilities.isGrounded) and computed differently per adapter: a flag read on built-in, a live scan of active contacts on matter.
Adapter-specific escapes exist ((this.body as MatterAdapter.Body).frictionAir,
adapter.matter.Constraint.create(...)) but are not portable — flag them if you
use them. adapter.capabilities (constraints,
continuousCollisionDetection, sleepingBodies, raycasts, raycasts3d,
velocityLimit, isGrounded) is the portable way to branch.
Symptom → cause
| symptom | cause |
|---|---|
| entity jitters or does not move | assigning pos instead of applying force |
| collision handler runs twice per frame | legacy onCollision on a dynamic pair (built-in) — use onCollisionActive |
return false from a handler does nothing |
return values are only honoured by legacy onCollision — use isSensor / isTrigger |
crash or a null body when spawning from onCollisionStart |
world mutated during contact dispatch (locked world under planck) |
raycast / queryAABB finds nothing |
isKinematic still true, or no world.update yet (built-in) |
querySphere / raycast3d is not a function |
2D adapter, or built-in without a 3D broadphase (sortOn !== "depth") |
| a 3D contact reports no overlap to push against | read overlapNZ / overlapZ — a Z-axis MTV leaves overlapN / overlapV at zero |
| two 3D objects never separate in depth | one of them carries a planar shape; a mixed pair is tested as the box's XY footprint, unbounded along Z |
response.depth / response.normal are undefined |
reading a legacy onCollision response — it carries overlap / overlapN |
onCollisionEnd handler throws on response |
built-in dispatches it with undefined |
| off-screen bodies stop simulating | built-in gating on inViewport; set alwaysUpdate |
| forces do nothing after switching adapter | magnitude units differ — re-tune, don't reuse numbers |
body.position disagrees with renderable.pos on matter |
matter stores the centroid, melonJS the top-left — the adapter offsets between them |
| a Tiled polyline becomes a solid box on matter | matter cannot build a zero-area polygon; the adapter falls back to its AABB |
Related skills
melonjs-renderables—isKinematic, which also gates the broadphasemelonjs-tilemaps— collision shapes authored in Tiled