2D physics
Box2D-backed physics as aspects on Node2D. Five pieces:
Shape2D— pure geometry (accessornode.shape). Does nothing alone; the others turn it into a fixture.Physics2D— a rigid body (node.physics): static, kinematic, or dynamic.Trigger2D— a sensor zone (node.trigger): firesenter/exit, blocks nothing.OneWay2D— a one-way surface (node.oneWay): the semisolid ledge of a platformer.CharacterController2D— a kinematic collide-and-slide mover (node.controller), for a platformer hero or a top-down one.
At a glance
class Walk extends Aspect<'walk', Node2D> {
updateBefore() { // early phase: feeds this frame's step
const dir = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)
this.node.get(CharacterController2D)?.move(dir * 300)
}
}
async function main() {
const scene = new Scene2D()
const dirt = await Texture2D.load(asset('./dirt.png'))
const heroTex = await Texture2D.load(asset('./hero.png'))
const ground = new Sprite({ texture: dirt, size: [640, 64], position: [0, -200] })
.aspect(Shape2D, {}) // collider = exactly the drawn rect
.aspect(Physics2D, { motion: 'static' })
const crate = new Sprite({ texture: dirt, size: [32, 32], position: [120, 100] })
.aspect(Shape2D, {})
.aspect(Physics2D, { bounce: 0.2 })
crate.addEventListener('enter', (other, c) => console.log('crate landed:', other === ground, c.speed))
const hero = new Sprite({ texture: heroTex, anchor: [0.5, 1], size: [32, 48] })
.aspect(Shape2D, { capsule: { from: [0, 16], to: [0, 32], radius: 16 } })
.aspect(CharacterController2D)
.aspect(Walk)
Input.on('keydown', e => {
if (e.code === 'Space' && hero.controller.grounded) hero.controller.velocityY = 700
})
scene.add(ground, crate, hero)
scene.open()
}
main()No Physics2D.configure() anywhere: the world builds itself on the first body.
The world
Physics2D.configure(config?: {
gravity?: Vec2Like // world units/s², Y-up (down = negative Y); default [0, -980]
pixelsPerMeter?: number // tunes Box2D's internal tolerances; you author in world units; default 64
subSteps?: number // solver sub-steps per fixed step; default 4
})
Physics2D.gravity // rw, live
Physics2D.supported // does this host ship 2D physics? (false, no throw, without a 2D engine)
Physics2D.interpolation // rw, global; default trueconfigure is optional and non-destructive. The world is created lazily by the first body (with
the defaults above), and calling configure later changes gravity live, leaving every existing
body alone.
pixelsPerMeter only applies before the world exists — it is a global solver tolerance.
reading Physics2D.gravity returns the last value assigned to it (initially
[0, -980]); a configure({ gravity }) — including a scene file's env.gravity — does not
update what it reads.
There is one world for every Scene2D: a body in a scene that is not open still simulates and
collides. It advances only while a 2D scene is open (or shown in a
UIImage), on the game clock — Time.scale slows it
and Time.paused freezes it.
The simulation runs at a fixed 60 Hz, at most 4 steps per frame (a longer stall drops the backlog
rather than teleporting everything). Rendered transforms are interpolated between steps;
Physics2D.interpolation = false writes authoritative transforms on stepped frames only — cheaper
with many bodies, at the cost of stutter between steps.
Host gating
On a build without physics support the whole family is silently inert: bodies get id 0, every
call no-ops, no events fire. Guard gameplay that depends on physics with Physics2D.supported,
which is false (never a throw) where there is no 2D engine at all — the web (Web Lite), a 3d /
core engine variant.
Shape2D — geometry
node.aspect(Shape2D, {
box?: [hw, hh] // HALF-extents, world units
circle?: number // radius
capsule?: { from: Vec2Like, to: Vec2Like, radius: number }
segment?: { from: Vec2Like, to: Vec2Like } // a single thin edge
polygon?: Vec2Like[] // CONVEX, up to 8 points (a hull is computed)
chain?: Vec2Like[] // a polyline — see below
loop?: boolean // chain: close the contour
flip?: boolean // chain: solid side is the other one
origin?: Vec2Like // the collider's centre, relative to the node
})Give exactly one kind, or {} — which measures the rect the node actually draws: the
sprite's size × the node's scale, positioned by its anchor. On a sprite anchored at its feet
(anchor: [0.5, 1]) that puts the collider around the art instead of half of it underground, so
most nodes never need explicit numbers. A node with no sprite falls back to 16×16.
boxtakes half-extents:box: [12, 20]is a 24×40 box.originapplies to every kind — it translates a capsule's, segment's, polygon's and chain's points too.- The auto shape is a snapshot taken when the fixture is built. Growing the sprite later does not resize the collider; re-configure the aspect to re-measure.
- The auto shape reads
sprite.size— which aSpriteAnimationsizedoes not change: give a sheet-backed sprite its cellsizetoo, or the collider wraps the whole sheet. - A polygon with more than 8 points builds no collider at all.
Re-configuring rebuilds it in place
node.aspect(Shape2D, { capsule: CROUCHED }) // same body, same id, same velocity, same positionThe fixture is swapped, not the body. Nothing happens when the geometry is unchanged, so re-applying the same options every frame is free. This is what makes crouching work, and what lets an editor edit a live collider.
chain — one long concave surface
hill.aspect(Shape2D, { chain: [[0, 0], [200, 40], [400, 10], [600, 90]], flip: true })
.aspect(Physics2D, { motion: 'static' })A polygon is convex and capped at 8 points — a concave outline passed to it is silently replaced by
its hull. chain is the shape for terrain contours, cave walls and race-track edges: any number of
points, concave, and seam-free, because its segments know their neighbours and never produce a
normal from an internal vertex. (Splitting one flat surface into a row of boxes does — a body sliding
over the joint catches on an edge that isn't visually there.)
Pass only the points that should be solid; the tangent points Box2D needs at the ends are synthesised for you.
a chain is one-sided — solid on the right of the point order. In a Y-up world, points
going left-to-right are solid from below, so ground drawn that way needs flip: true. It also
needs a static body (a polyline has no area, so no mass — a dynamic one throws), at least 2
points (4 with loop), and it must not self-intersect.
Physics2D — rigid bodies
node.aspect(Physics2D, {
motion?: 'static' | 'kinematic' | 'dynamic' // default 'dynamic'
mass?: number // omitted = from the collider's AREA
friction?: number // default 0.6
bounce?: number // 0..1; default 0
fixedRotation?: boolean // lock rotation; default false
bullet?: boolean // continuous collision for fast movers; default false
gravityScale?: number // per-body multiplier; default 1
linearDamping?: number // default 0
angularDamping?: number // default 0
group?: PhysicsGroup2D // collision filtering — see below
})Requires a Shape2D on the same node — attach it first (Physics2D throws otherwise).
Everything is live:
node.physics.velocity = [vx, vy] // world units/s (getter: fresh Vec2)
node.physics.angularVelocity = 90 // degrees/s, rw
node.physics.motion = 'static' // freeze a crate; hand a ragdoll to 'dynamic'
node.physics.mass = 4
node.physics.friction = 1 // dry tarmac
node.physics.bounce = 0.8
node.physics.gravityScale = 0
node.physics.linearDamping / .angularDamping
node.physics.group = ghost
node.physics.enabled = false // pause this body (write-only)
node.physics.awake = true // wake a sleeping body (write-only)
node.physics.applyImpulse([x, y]): this // at the centre of mass — never spins
node.physics.applyImpulseAt(v, worldPoint): this // …the lever arm becomes spin
node.physics.applyForce([x, y]): this
node.physics.id // native body id; 0 without physics supportmass, friction, bounce
mass omitted means the collider's area — a big crate really is heavier than a small one.
Setting it scales the shape-derived mass data, so the rotational inertia keeps its ratio and a
lopsided collider still tips the way its geometry says.
friction is a property of the surface: 0 is frictionless, ~1 is grippy asphalt. Two bodies
combine as sqrt(a × b), so the lower value wins the argument — a grippy crate on a slippery floor
still slides.
bounce is the coefficient of restitution: the fraction of the approach speed returned on impact.
Two bodies combine as max — the bouncier one wins, the opposite of friction. Below roughly
pixelsPerMeter units/s no bounce is applied at all, which is what stops a ball jittering on the
floor forever.
Physics owns the transform
For a dynamic or kinematic body the native step writes the node's transform. Don't set
node.position per frame — drive dynamic bodies with velocity / applyImpulse. A position or
rotation write is a teleport that reaches the body (this replaced the old moveTo); the body
keeps its velocity across it. Reading node.position is always correct. A transform tween
(animateTo) does not reach the body — it moves the drawing and leaves the collider behind.
a teleport does not wake a sleeping body. A dynamic body that has come to rest stays
where you put it — even in mid-air — until something wakes it: set physics.awake = true after
moving it.
keep bodies and sensors on root nodes. A body parented to a node that moves does not follow it — only a direct write to its own transform is routed.
Trigger2D — sensor zones
const goal = new Node2D()
.aspect(Shape2D, { box: [32, 64] })
.aspect(Trigger2D) // { group? }
goal.addEventListener('enter', other => console.log('goal reached by', other.name))
goal.position = [400, 64] // the zone follows — it is kinematic
goal.trigger.enabled = false // switch it off; no need to park it off-screen
goal.trigger.group
goal.trigger.idA sensor built from the node's Shape2D: overlapping bodies fire the node's enter/exit events
instead of colliding. Characters are detected too — they carry a hidden body for exactly this, so
pickups and hazard zones need no special code.
OneWay2D — one-way surfaces
ledge.aspect(Shape2D, { box: [64, 4] })
.aspect(Physics2D, { motion: 'static' })
.aspect(OneWay2D, {
normal?: Vec2Like // which side is solid; default [0, 1] (pass through from below)
arc?: number // tolerance in degrees; default 90
enabled?: boolean // default true; false = solid from both sides
})Jump up through it, stand on it. normal generalises the idea — [1, 0] is a wall you can only
enter from the left. OneWay2D needs a Physics2D body on the node (it throws otherwise).
Honoured by characters, with no per-frame cost. Dropping down through
one is the character's business: dropThrough.
one-way behaviour for ordinary rigid bodies. A Physics2D body collides with
a OneWay2D surface from both sides — a ball thrown up from below bounces off its underside.
Collision groups
// game/groups.ts
export const player = Physics2D.addGroup('player')
export const bullets = Physics2D.addGroup('bullets').ignoreSelf().ignore(player)
bullet.aspect(Physics2D, { motion: 'dynamic', bullet: true, group: bullets })Physics2D.addGroup(label?: string): PhysicsGroup2D // label is for debugging only
group.ignore(...groups: PhysicsGroup2D[]): this // stop colliding with these (both ways)
group.ignoreSelf(): this // members don't collide with each other
group.label, group.bit // readonlyignore() is symmetric — naming the relation on one side is enough — and subtractive: a
group collides with everything except what it ignores. That matters more than it sounds. The
enumerative form ("collides only with ground and enemy") decays: add a crate group a month later
and the bullets start flying through it, with nothing to notice. Here a new group is collided with by
default.
Safe to call after bodies exist; their filters are re-pushed. Physics2D.defaultGroup names the
implicit group of every body that never chose one. There are 30 groups (a category has to cross
to the engine as a 32-bit number, and two bits are reserved); addGroup throws past that.
CharacterController2D
A kinematic collide-and-slide mover — not a rigid body, so it does not inherit the solver's
bounce, does not catch on the seam between two colliders, and knows what it is standing on. A
character that should be pushed around by the world is a plain Physics2D body instead.
node.aspect(CharacterController2D, {
gravityScale?: number // default 1; 0 = free mode
maxSlope?: number // walkable slope limit in degrees; default 45
group?: PhysicsGroup2D
})It needs a capsule Shape2D; one is derived from the sprite if you don't give it one. It does
not use Physics2D, and it costs zero JS per frame — everything runs on the engine's fixed clock.
The engine writes only its position: rotation and scale stay the node's. c.id is the native
character id (0 without physics support).
The two halves of the velocity
c.move(x) // HORIZONTAL: a per-frame command, world units/s
c.velocityY = 700 // VERTICAL: latched ballistic state
c.velocity // read = measured; write = latch the whole vector
c.velocity.y = 700 // same as velocityY = 700 (compiled to the owner's setter; direct spelling only —
// a stored copy is just a copy). c.velocity.x = 300 latches the whole vectorThey differ in nature, so they are different channels:
- Horizontal is muscle-driven and not conserved. The command expires each frame: no call this frame means standing still, and releasing the keys needs no explicit zero.
- Vertical is ballistic and latched. Gravity integrates into it; you seed it for a jump, a bounce pad, an explosion. There is deliberately no ground check — coyote time, double jumps and wall jumps all need your own condition.
Writing velocity latches the whole vector (a knockback, a launch pad). It persists, gravity still
arcs it, and it stays until move() takes the axis back:
hero.controller.velocity = [dir.x * 600, 400] // hit by the blast
if (thrown) { if (hero.controller.grounded) thrown = false } // landing ends it
else hero.controller.move(ix * SPEED) // …and this reclaims the axisNothing clears a latch by itself, landing included — a kinematic controller has no friction.
move() takes a velocity, not a displacement. Passing v * dt gives a character 60×
too slow. Call it from an aspect's updateBefore so this frame's step consumes it.
Reading velocity reports what the solver ended up with: walk into a wall and it reads ~0 while
you are commanding 300.
Free mode — top-down, swimming, ladders
class TopDown extends Aspect<'topDown', Node2D> {
speed = 200
updateBefore() {
const x = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)
const y = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
this.node.get(CharacterController2D)?.move(x * this.speed, y * this.speed) // or move([x, y])
}
}
hero.aspect(CharacterController2D, { gravityScale: 0 }).aspect(TopDown)gravityScale = 0 removes gravity, ground and stick-to-floor, and move() takes both
components as one expiring command. Top-down versus platformer is this one number — not two
controllers. It is also how a ladder works: set gravityScale = 0 on entering the ladder trigger and
back to 1 on leaving.
A two-component move() with gravity still on warns once: it would overwrite the falling speed every
frame and the character would hang in the air.
Ground
c.grounded // standing on walkable ground
c.groundState // 'ground' | 'slope' | 'air'
c.groundNormal // the surface under the feet
c.groundNode // WHAT you are standing onmaxSlope splits 'ground' from 'slope'; the game decides what a too-steep face means. A
kinematic platform carries the character automatically, whether it is driven by velocity or by
writing its position.
Collisions
const wall = c.collisions.find(h => Math.abs(h.normal.x) > 0.7)
if (wall && jumpPressed) c.velocity = [-wall.normal.x * KICK, JUMP]
for (const h of c.collisions) h.node?.get(Physics2D)?.applyImpulse([-h.normal.x * PUSH, 0])Every surface the mover pushed out of during the last step (at most 8), with the normal pointing back at the character. Wall jumps and box pushing are written against this.
solid contacts do not arrive as enter events — the mover is not a Box2D body.
Sensor overlaps do.
Dropping through
if (downPressed && jumpPressed && c.grounded) {
const platform = c.groundNode
c.dropThrough = platform
setTimeout(() => { if (c.dropThrough === platform) c.dropThrough = null }, 200)
}While dropThrough names a node, that node's OneWay2D surfaces are not solid for this character.
It is a plain latch — whoever sets it clears it. Because it names one node, the timing is not
delicate: a too-long timeout merely means you could have re-landed on that platform for a moment, and
the platform below stays solid either way.
Crouching
hero.aspect(Shape2D, { capsule: CROUCHED }) // always fits — you are shrinking
hero.aspect(Shape2D, { capsule: STANDING }) // may be refused under a low ceiling
if (hero.controller.resizing) { /* still crouched — call it again next frame */ }Resizing goes through Shape2D itself. A refusal changes nothing, so the retry is just the same call
again — and it is an exact headroom test against the real capsule, unlike a hand-rolled raycast (a
ray is a line; a capsule has girth). The feet stay planted, so the character neither hovers nor
sinks.
enter / exit events
hero.addEventListener('enter', (other, contact) => {}) // contact: { point, normal, speed }
hero.addEventListener('exit', other => {})Contact begin/end (solid vs solid) and sensor overlap begin/end both arrive as the node's
enter/exit, delivered to both nodes with the other as the argument.
A contact begin carries a manifold: the world point, the normal of the surface you hit
(pointing back at you, so normal.y > 0.7 reads as "I landed on top of it"), and the speed you
approached at — scale an impact sound with it; a touch gentler than about pixelsPerMeter units/s
reports 0. Sensor overlaps and exit carry zeros.
adding the listener is what enables contact events on that body. Without one, nothing is reported and nothing is paid — which is what keeps two hundred crates from pushing two hundred pairs across the bridge every frame.
events, pointer hits and query hits name a node only while the engine holds it — in a
scene or under a parent. A body on a loose root node still collides, but it is not reported: no
enter on either side, and RayHit.node is null.
Queries
type Query2D = { groups?: PhysicsGroup2D[], ignore?: Node2D | Node2D[] }
Physics2D.raycast(from, to, q?): RayHit | null
Physics2D.raycastAll(from, to, q?): RayHit[] // nearest first, capped at 32
Physics2D.overlapPoint(p, q?): Node2D | null // topmost by DRAW order
Physics2D.overlapCircle(centre, radius, q?): Node2D[] // at most 32 (as the two below)
Physics2D.overlapBox(centre, half, q?): Node2D[] // axis-aligned
Physics2D.overlapCapsule(from, to, radius, q?): Node2D[]
// RayHit = { node, point: Vec2, normal: Vec2, fraction /* 0..1 along the ray */ }Queries see sensors and characters as well as solid bodies. ignore is the "don't hit myself" case
— a ray fired from inside its own body needs it, and Box2D's closest-ray API cannot express it.
overlapCapsule is the shape a directed question has: a beam with width, a sword arc, "does my
radius fit through this gap" — none of which an axis-aligned box can express. It is also the
swept test: a circle of radius r moving from A to B during a frame sweeps exactly that capsule,
so a fast projectile checked this way cannot tunnel through a thin wall the way two separate position
checks do.
Pointer picking
A node with a Shape2D plus a Physics2D, Trigger2D or CharacterController2D is
pointer-hittable: taps hit-test via Physics2D.overlapPoint, so the node receives click /
touchstart. See pointer events & gestures. For picking without bodies, use
scene.pick().
Physics never reads the tilemap
Collision comes from scene objects only — there is no way to derive it from a
tilemap or a tileset, by design. A platformer's surfaces are nodes with
box / polygon / chain; its art is drawn separately, and the two are unrelated. In a top-down
game the tilemap is usually the floor you walk on, and what's solid — walls, trees, props — is
objects anyway.
Pitfalls
// ✗ full extents — box takes HALF-extents
hero.aspect(Shape2D, { box: [24, 40] }) // that's a 48×80 box
// ✓ half the sprite size, or just measure the art
hero.aspect(Shape2D, {})
// ✗ a displacement — move() takes a VELOCITY
hero.controller.move(SPEED * dt)
// ✓
hero.controller.move(SPEED)
// ✗ moving a dynamic body by writing its transform every frame
crate.x += 2 // in an update(dt)
// ✓ drive the body
crate.physics.velocity = [120, crate.physics.velocity.y]
// ✗ ground drawn left-to-right is solid from BELOW
cliff.aspect(Shape2D, { chain: leftToRight })
// ✓
cliff.aspect(Shape2D, { chain: leftToRight, flip: true })See also
- Node2D —
enter/exitlisteners, transform ownership - Pointer events & gestures —
click/touchstarton hittable nodes - Scene2D — body-free
pick(), scene-level pointer events - Sprite — draw scale vs collider size