2D physics

Box2D-backed physics as aspects on Node2D. Five pieces:

  • Shape2D — pure geometry (accessor node.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): fires enter/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

TypeScript
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

TypeScript
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 true

configure 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.

Note

pixelsPerMeter only applies before the world exists — it is a global solver tolerance.

Note

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

TypeScript
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.

  • box takes half-extents: box: [12, 20] is a 24×40 box.
  • origin applies 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 a SpriteAnimation size does not change: give a sheet-backed sprite its cell size too, 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

TypeScript
node.aspect(Shape2D, { capsule: CROUCHED })   // same body, same id, same velocity, same position

The 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

TypeScript
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.

Note

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

TypeScript
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:

TypeScript
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 support

mass, 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.

Note

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.

Note

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

TypeScript
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.id

A 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

TypeScript
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.

Not implemented

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

TypeScript
// 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 })
TypeScript
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                               // readonly

ignore() 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.

TypeScript
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

TypeScript
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 vector

They 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:

TypeScript
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 axis

Nothing clears a latch by itself, landing included — a kinematic controller has no friction.

Note

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

TypeScript
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

TypeScript
c.grounded                 // standing on walkable ground
c.groundState              // 'ground' | 'slope' | 'air'
c.groundNormal             // the surface under the feet
c.groundNode               // WHAT you are standing on

maxSlope 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

TypeScript
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.

Note

solid contacts do not arrive as enter events — the mover is not a Box2D body. Sensor overlaps do.

Dropping through

TypeScript
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

TypeScript
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

TypeScript
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.

Note

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.

Note

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

TypeScript
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

TypeScript
// ✗ 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