@melonjs/planck-adapter
v1.0.0
Published
melonJS physics adapter for planck.js (Box2D)
Downloads
59
Maintainers
Readme
@melonjs/planck-adapter

A planck.js physics adapter for melonJS — drops in for the built-in SAT physics and gives you Box2D's rigid-body simulation, continuous collision detection, rotational dynamics, sleeping bodies, joints, and raycasting.
Planck is a faithful JavaScript/TypeScript port of Box2D 2.3.0 — the same simulator behind Angry Birds, Limbo, and many of the touch-era classic mobile platformers. It is a sync, zero-WASM, MIT-licensed library that ships as plain ES modules.
Per-object collision dispatch is already wired up. Every Renderable receives onCollisionStart(response, other), onCollisionActive(response, other), and onCollisionEnd(response, other) callbacks — the same shape you use under the built-in adapter or @melonjs/matter-adapter. No rewrite, no contact-list firehose to filter yourself.
Installation
npm install @melonjs/planck-adapterplanck is bundled in as a regular dependency, so you don't need to install it yourself — that's the whole point of the adapter. The only peer dependency is melonjs ≥ 19.5 (because melonJS is the one providing the PhysicsAdapter interface this package implements).
Node 24+ is required at install time —
planckitself declaresengines.node: ">=24.0"and this constraint flows through. The runtime is the browser, so this only affects the build/install toolchain. If you're on an older Node version, your package manager will warn (npm) or refuse (pnpm withengine-strict).
Usage
Pass a PlanckAdapter instance as the physic option when constructing your Application:
import { PlanckAdapter } from "@melonjs/planck-adapter";
import { Application, video } from "melonjs";
const app = new Application(800, 600, {
parent: "screen",
renderer: video.AUTO,
physic: new PlanckAdapter({
gravity: { x: 0, y: 320 },
}),
});That's it — every renderable that declares a bodyDef gets registered with planck automatically on Container.addChild, and the rest of your game code (collision handlers, velocity reads, gravity tweaks, etc.) talks to the shared PhysicsAdapter interface so it works with either adapter.
Constructor options
new PlanckAdapter({
gravity?: { x: number; y: number }; // default { x: 0, y: 320 } ≈ 10 m/s² pulling down
pixelsPerMeter?: number; // default 32
subSteps?: number; // default 1
velocityIterations?: number; // default 8 (Box2D recommendation)
positionIterations?: number; // default 3 (Box2D recommendation)
})gravity
Specified in pixels per second², not meters. The adapter converts to meters internally using pixelsPerMeter. The default (0, 320) ≈ 10 m/s² at pixelsPerMeter = 32 is Earth-like gravity pulling down — adjust for arcade feel.
pixelsPerMeter
Planck (and Box2D) operates in meters internally; the adapter converts to/from melonJS's pixel space at every boundary (positions, velocities, gravity, forces, AABB queries). The default 32 means one 32×32 sprite is one Box2D unit — which falls inside Box2D's tuned sweet spot (0.1 – 10 m). Bumping this makes the world feel "smaller" (objects act heavier); lowering makes everything feel "bigger" and risks solver instability if bodies grow past Box2D's tuned scale.
Pick once at construction time and stick with it — every body in the world shares the same scale.
subSteps
Divides each physics step into N sub-steps of dt / N seconds. Box2D's narrow phase isn't swept; a body moving more than ~one collision radius per tick can tunnel through a wall or another body. For very fast objects bump this to 2–4. The default 1 matches plain Box2D behaviour.
velocityIterations / positionIterations
Box2D solver iteration counts. The defaults are Box2D's recommended 8 and 3. Increase positionIterations for stiffer stacks (less jitter at the cost of CPU); increase velocityIterations for crisper restitution between bouncing bodies.
Collision Events
The adapter dispatches planck's two native collision events to renderable hooks, plus an active-pair pass each step:
class Player extends Sprite {
// fires once when two bodies begin contact (planck: begin-contact)
onCollisionStart(response, other) { /* stomp, pickup, trigger entry */ }
// fires every frame while two bodies remain in contact (adapter walks
// the world contact list after each step)
onCollisionActive(response, other) { /* sustained damage, conveyor friction */ }
// fires once when two bodies separate (planck: end-contact)
onCollisionEnd(response, other) { /* left the platform, exited a zone */ }
}Implement only the ones you need — missing methods are silently skipped. The same three handlers also fire on the builtin SAT adapter and on @melonjs/matter-adapter, so handler code stays portable.
The response object
The first argument passed to every collision hook is:
response = {
a: Renderable, // this renderable (the one whose handler is firing)
b: Renderable, // the other renderable
normal: { x: number, y: number }, // unit MTV for `a` (direction to escape)
depth: number, // penetration depth in pixels (always positive)
pair: planck.Contact, // raw planck Contact (manifold, points, fixtures, …)
}normal direction — the minimum-translation vector for the receiver. It points in the direction this (a.k.a. response.a) must move to separate from other. Each side of the dispatch sees its own MTV, so the normals on the two handlers are mirrored.
In canvas coordinates (y grows downward):
normal.y < -0.7→ push me up to escape ⇒ I'm sitting on top ofother(classic stomp / landing).normal.y > 0.7→ push me down to escape ⇒ I'm underneath (head-bumped a ceiling, got stomped on).Math.abs(normal.x) > 0.7→ mostly horizontal contact ⇒ side hit.
response.pair is planck's native Contact (with getFixtureA(), getFixtureB(), getManifold(), getWorldManifold(), etc.) for advanced use. The normal and depth fields are derived from getWorldManifold and getManifold().points[i].separation respectively, both expressed in melonJS pixel space.
Body helper methods
The canonical portable surface is the PhysicsAdapter interface — every method below is also reachable as adapter.X(renderable, ...). As a convenience this adapter bolts the portable operations onto renderable.body so the idiomatic form available on built-in me.Body works here too:
// Linear kinematics — spliced onto body (planck.Body has no equivalent)
body.setVelocity(x, y) // ⇔ adapter.setVelocity(renderable, { x, y })
body.getVelocity(out?) // ⇔ adapter.getVelocity(renderable, out)
body.applyForce(x, y) // ⇔ adapter.applyForce(renderable, { x, y })
body.applyForce(x, y, pointX, pointY) // off-centre ⇒ generates torque (planck native lever-arm)
body.applyImpulse(x, y) // ⇔ adapter.applyImpulse(renderable, { x, y })
// Angular kinematics — NATIVE on planck.Body, reused as-is
body.setAngle(rad) // planck.Body.setAngle / adapter.setAngle
body.getAngle() // planck.Body.getAngle / adapter.getAngle
body.setAngularVelocity(omega) // planck.Body.setAngularVelocity / adapter.setAngularVelocity
body.getAngularVelocity() // planck.Body.getAngularVelocity / adapter.getAngularVelocity
body.applyTorque(t) // planck.Body.applyTorque / adapter.applyTorque
// Body state — spliced helpers wrap planck's per-fixture / mass-data APIs
body.setSensor(isSensor?) // toggles every fixture's sensor flag
body.setStatic(isStatic?) // ⇔ planck.Body.setType("static" | "dynamic")
body.setMass(m) // wraps planck.Body.setMassData (preserves center + inertia)
body.setBounce(r) // sets every fixture's restitution
body.setGravityScale(s) // planck.Body.setGravityScale (native, same name & sig)
body.setCollisionMask(mask) // sets every fixture's filterMaskBits
body.setCollisionType(type) // sets every fixture's filterCategoryBitsImplementation note: the helpers marked "NATIVE on planck.Body" are NOT spliced — planck's class already exposes them with compatible signatures, so the PlanckAdapter.Body type surfaces them via the intersection. Methods like applyForce, setVelocity, and setStatic that DO collide with native planck signatures (or use different shapes) are spliced on top of the body, replacing the native form with the portable one. The original planck.Body.applyForce(Vec2, Vec2, wake) is still reachable via adapter.world.getBodyList() if you absolutely need it, but you should generally prefer the portable form.
Reaching planck-native body methods
Planck exposes a rich body API (sleeping control, mass data, CCD bullet flag, transform overrides, etc.) directly on the planck.Body instance. Cast to the published PlanckAdapter.Body type to keep type-checking happy without taking a direct planck import:
import { PlanckAdapter } from "@melonjs/planck-adapter";
// enable continuous collision detection for fast-moving objects (e.g. cue ball):
(ball.body as PlanckAdapter.Body).setBullet(true);
// suppress sleeping on a body that must always respond to user input:
(player.body as PlanckAdapter.Body).setSleepingAllowed(false);
// read planck-native mass:
const mass = (crate.body as PlanckAdapter.Body).getMass();PlanckAdapter.Body is planck.Body & PhysicsBody — you get planck's full instance methods plus the portable helper methods, without needing to import planck yourself. Code that does this is planck-only by definition; for portable rotation use the angular kinematic methods above.
Raycasting
adapter.raycast(from: Vector2d, to: Vector2d) → RaycastHit | nullShoot a ray through the world and get the first body hit:
const hit = adapter.raycast(new Vector2d(0, 0), new Vector2d(800, 600));
if (hit) {
// hit.renderable — the renderable the ray entered
// hit.point — world-space entry point on the body's surface
// hit.normal — outward-facing surface normal at the entry
// hit.fraction — 0..1 along the ray, from `from` to `to`
}Portable — same shape under the builtin SAT adapter, @melonjs/matter-adapter, and this one. Implementation under planck uses Box2D's native fractional ray-cast, so curved/rotated bodies report exact-surface hit geometry rather than a body-centre approximation.
Region queries
adapter.queryAABB(rect: Rect) → Renderable[]Return every renderable whose body bounds overlap the given rectangle. Useful for area-of-effect damage, mouse / touch picking, trigger-zone sweeps, AI awareness checks. Portable — same call under builtin, matter, and planck. Under planck the implementation uses Box2D's native AABB query.
Direct engine access
For planck-specific features that don't fit the portable PhysicsAdapter surface — joints, particle systems, raw events — the adapter exposes two escape hatches:
const adapter = app.world.adapter as PlanckAdapter;
// The whole planck namespace. Modules are named exactly as in planck's docs
// (planck.RevoluteJoint, planck.DistanceJoint, planck.Box, planck.Circle, ...).
adapter.planck; // typeof planck
const joint = new adapter.planck.RevoluteJoint(
{ collideConnected: false },
a.body as PlanckAdapter.Body,
b.body as PlanckAdapter.Body,
new adapter.planck.Vec2(0, 0),
);
adapter.world.createJoint(joint);
// The underlying planck.World instance.
adapter.world; // planck.World — full body / fixture / joint / contact APIsAny code that touches adapter.planck.* or adapter.world.* is planck-only — it will not run on the built-in adapter or any future adapter. Use the PhysicsAdapter methods (setVelocity, applyForce, setStatic, setSensor, raycast, …) for anything that should stay portable.
Body Definitions
melonJS body definitions (BodyDefinition) are mapped to planck bodies + fixtures. The keys you can set are the same as for the builtin adapter:
this.bodyDef = {
type: "dynamic" | "static" | "kinematic",
shapes: BodyShape[], // Rect, Polygon, Ellipse (Ellipse → circle approximation)
collisionType?: number,
collisionMask?: number,
maxVelocity?: { x, y }, // emulated via afterStep clamp
frictionAir?: number, // ⇒ planck.Body.setLinearDamping (scalar only)
restitution?: number,
density?: number, // ⇒ fixture density (kg/m²)
friction?: number, // ⇒ fixture surface friction coefficient
gravityScale?: number,
isSensor?: boolean,
fixedRotation?: boolean, // planck native — defaults to true (matches SAT axis-aligned bodies)
};Internally each shape becomes a planck.Fixture attached to a single planck.Body. Multi-shape defs produce a multi-fixture body (Box2D's compound-shape pattern).
Collision filter API
For planck users, planck's per-fixture categoryBits / maskBits is exposed as a live alias of the legacy body.collisionType / collisionMask. Writes propagate to every fixture on the body:
// All four lines do the same thing — pick whichever convention you prefer:
body.collisionType = collision.types.PLAYER_OBJECT;
body.getFixtureList().setFilterCategoryBits(collision.types.PLAYER_OBJECT);
body.collisionMask = collision.types.ENEMY_OBJECT;
body.getFixtureList().setFilterMaskBits(collision.types.ENEMY_OBJECT);Behavioural notes when porting from the builtin adapter
- Bodies have full rotational dynamics by default for non-fixedRotation bodies. If your game code assumes axis-aligned bodies (reads
posand expects an unrotated rect), keepfixedRotation: true(the default). - Polylines (zero-thickness lines) don't translate. planck — like Box2D — can't make a body from collinear vertices, and polygons must be convex with ≤8 vertices. Replace TMX polylines with thin rectangles at load time, or load and rewrite them post-load.
- Ellipses are approximated as circles with the average radius. For tall/narrow ellipses this is a poor fit; a polygon hull is a better choice when accuracy matters.
maxVelocityis emulated. Box2D has no native velocity cap; the adapter clamps each body's velocity after every step.isGroundedis literal. It returnstruewhenever any contact pair has the other body's center below this one's. Inside anonCollisionStarthandler for a stomp, the enemy you just landed on already counts as "ground" — so don't use!isGroundedas a proxy for "I was airborne before this contact." Use the body's pre-contact velocity instead (vel.y > 0⇒ falling at impact).- Forces are real Newtons.
applyForce(x, y)is integrated asforce / mass * dt². Magnitudes feel ~100× smaller than the legacy SAT adapter — for jumps and dashes usesetVelocity(immediate) orapplyImpulse(Δv = J / m) instead.
Behavioural notes when porting from @melonjs/matter-adapter
Most of the portable surface (bodyDef, setVelocity, applyForce(x, y, px, py), the three collision hooks, setSensor, setStatic, raycast, queryAABB) works identically. The differences:
- Box2D works in meters. Forces, velocities, gravity, and positions go through
pixelsPerMeterconversion. You don't have to do anything explicit; the boundary is invisible, but it means tuning numbers from a matter game don't transfer directly. - Per-body
gravityScaleis native — nobeforeUpdatecounter-force emulation, no surprises near the threshold of 0. - Sleeping is native and unconditional. Idle bodies sleep automatically (set
setSleepingAllowed(false)on a body that must remain responsive without input). - Polygons must be convex with ≤8 vertices. Matter accepts concave polygons (decomposed by
poly-decomp); planck doesn't. Pre-decompose at TMX load time if your collision shapes include concave outlines. - No concept of "compound body" with sub-positions. All fixtures share the same body anchor; multi-shape bodies effectively compose collision geometry into one rigid mass. Matter's distinct sub-body positions don't translate.
Matter.Body.create({parts: [...]})style compound bodies become a single planck body with multiple fixtures. The adapter does this transparently.
A simple porting example
The same player class on the matter adapter, then ported here:
Before — @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,
physic: new MatterAdapter({ gravity: { x: 0, y: 5 } }),
});After — @melonjs/planck-adapter:
import { Application, collision, input, Rect, Sprite, video } from "melonjs";
import { PlanckAdapter } from "@melonjs/planck-adapter";
new Application(800, 600, {
parent: "screen",
renderer: video.AUTO,
// (1) Swap the adapter. Gravity is in px/s² now — pick a value that feels
// right for your sprite scale (32 px/m default ⇒ 320 ≈ Earth gravity).
physic: new PlanckAdapter({ gravity: { x: 0, y: 320 } }),
});The rest of the player class — bodyDef, applyForce magnitudes, setVelocity, collision hooks — typically ports unchanged. Tune WALK_FORCE upward if motion feels sluggish (Box2D's force-mass-dt² integration is similar to matter's but the unit scale through pixelsPerMeter shifts the numbers).
Recipes
Jump — instant upward impulse (Portable)
const vel = this.body.getVelocity();
this.body.setVelocity(vel.x, -JUMP_VEL); // preserves horizontal motionTrigger zone / coin pickup (Portable)
Mark the body as a sensor:
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);
}Spring / hinge between two bodies (Planck-only)
const adapter = app.world.adapter as PlanckAdapter;
if (adapter.capabilities.constraints) {
const joint = adapter.world.createJoint(new adapter.planck.DistanceJoint(
{
length: 80 / adapter.pixelsPerMeter, // remember: planck wants meters
frequencyHz: 4, // 0 = rigid, higher = stiffer
dampingRatio: 0.5,
},
anchor.body as PlanckAdapter.Body,
player.body as PlanckAdapter.Body,
anchor.body.getPosition(),
player.body.getPosition(),
));
}For a revolute (pin) joint use planck.RevoluteJoint; for a prismatic (slider) joint use planck.PrismaticJoint. The full Box2D joint set is exposed via adapter.planck.*.
CCD for fast objects (Planck-only)
For projectiles, break-shot cue balls, or other very fast bodies where the default narrow phase might miss a collision:
(projectile.body as PlanckAdapter.Body).setBullet(true);Planck's CCD treats bullet bodies with a swept narrow phase, eliminating tunneling at the cost of extra solver work. Use sparingly — only on the small handful of bodies that genuinely need it.
Disabling sleep on a body (Planck-only)
(player.body as PlanckAdapter.Body).setSleepingAllowed(false);Useful for the player or any body that must respond instantly to input regardless of whether it's been idle. Planck will skip the sleep heuristic for that body and keep integrating it every step.
Porting checklist
- Verify the boot banner shows
physic: @melonjs/planck-adapter - Pick a sensible
pixelsPerMeter(default 32 works for most 16/32-px games) - Set
gravityin px/s² (default 320 ≈ 10 m/s² at default scale) - Replace any
Matter.Body.applyForce(b, p, v)direct calls withbody.applyForce(x, y, px, py)(portable) - Convert any concave TMX polygons to convex pieces (planck can't decompose them)
- Add
setBullet(true)to any fast-moving projectiles - Pass
fixedRotation: trueinbodyDeffor anything that should stay axis-aligned
