2D physics
Box2D-backed physics as aspects on Node2D. Four pieces, mirroring the 3D family:
Shape2D— pure geometry (accessornode.shape). Does nothing alone; the two below turn it into a fixture.Physics2D— a rigid body (node.physics): static, kinematic, or dynamic.Trigger2D— a static sensor zone (node.trigger): firesenter/exit, blocks nothing.CharacterController2D— a platformer helper (node.controller) on top of a dynamic body.
At a glance
Physics2D.configure({ gravity: [0, -980] }) // once, BEFORE creating bodies
const ground = new Node2D()
.aspect(Shape2D, { segment: { from: [-500, 0], to: [500, 0] } })
.aspect(Physics2D, { motion: 'static' })
const hero = new Sprite({ texture: tex, anchor: [0.5, 1] })
.aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } })
.aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
hero.addEventListener('enter', other => console.log('touched', other.id))
hero.physics.applyImpulse([0, 400]) // jump
scene.add(ground, hero)Host gating
Physics2D.supported // static: does this build ship 2D physics?On a build without physics support the whole family is silently inert: bodies get id 0,
every call no-ops, no events fire — and pointer picking of nodes (below) never hits. Guard
gameplay that depends on physics with Physics2D.supported.
The world: Physics2D.configure
Physics2D.configure(config?: {
gravity?: Vec2Like // world units/s², Y-up (down = negative Y); default [0, -980]
pixelsPerMeter?: number // tunes Box2D's internal tolerances; you still author in world units; default 64
subSteps?: number // solver sub-steps per fixed step; default 4
})Creates — or resets — the physics world. Call it once, before creating any bodies; calling it again resets the world, orphaning existing bodies.
The simulation steps at a fixed 60 Hz. Rendered transforms are interpolated between steps:
Physics2D.interpolation = false // static, global; default trueTurn interpolation off to save per-frame transform writes with many moving bodies (they then advance in discrete 60 Hz steps).
Shape2D — geometry
node.aspect(Shape2D, {
box?: [hw, hh] // HALF-extents, world units
circle?: number // radius
capsule?: { from: Vec2Like, to: Vec2Like, radius: number } // between two local points
segment?: { from: Vec2Like, to: Vec2Like } // thin edge — static ground/walls/slopes
polygon?: Vec2Like[] // convex, up to 8 local points (hull is computed)
offset?: Vec2Like // local offset from the node origin (box/circle)
})Give exactly one of box / circle / capsule / segment / polygon. With none, the
shape derives a box from the node's sprite size (half-width × half-height; 16×16 fallback for
a bare Node2D).
boxtakes half-extents:box: [12, 20]is a 24×40 box.capsule,segment, andpolygonpoints are in node-local space and ignoreoffset.segmentis for static solid geometry only — it has no area (no density) and is never a sensor; don't use it withTrigger2Dor on dynamic bodies.
shapes never read the node's draw scale — all extents are raw world units. Scaling
a sprite 2× does not scale its collider.
Physics2D — rigid bodies
node.aspect(Physics2D, {
motion?: 'static' | 'kinematic' | 'dynamic' // default 'dynamic'
density?: number // default 1
friction?: number // default 0.3
restitution?: number // bounciness; default 0
fixedRotation?: boolean // lock rotation (platformer characters); default false
bullet?: boolean // continuous collision for fast movers; default false
gravityScale?: number // per-body gravity multiplier; default 1
})Requires a Shape2D on the same node — attach it first (Physics2D throws otherwise).
node.physics.velocity = [vx, vy] // linear velocity, world units/s (getter: fresh Vec2)
node.physics.angularVelocity = 90 // degrees/s (write-only)
node.physics.gravityScale = 0 // 1 = full gravity, 0 = floats, <0 = repelled
node.physics.linearDamping = 0.5 // write-only
node.physics.enabled = false // write-only: pause this body
node.physics.applyImpulse([x, y]): this // instantaneous impulse; wakes the body
node.physics.applyForce([x, y]): this // continuous force; wakes the body
node.physics.moveTo(p, deg = 0): this // teleport / drive a kinematic body to a world pose
node.physics.id // native body id (0 = detached / no physics support)Physics owns the transform
For a dynamic or kinematic body, the native physics step writes the node's transform. Don't
set node.position per frame — drive dynamic bodies with velocity / applyImpulse, kinematic
ones with moveTo. Reading node.position / worldPosition is always correct; the node
re-syncs from native on read. See Node2D.
keep bodies on root-level nodes. A body under a moving parent gets its transform written in world terms by the physics step — parent motion and physics will fight.
Trigger2D — sensor zones
const goal = new Node2D()
.aspect(Shape2D, { box: [32, 64] })
.aspect(Trigger2D) // no options
goal.addEventListener('enter', other => win(other))
goal.trigger.moveTo(p: Vec2Like): this // reposition the zone to a world point
goal.trigger.id // native body id (0 = no physics support)A trigger is a static sensor body built from the node's Shape2D: overlapping bodies fire
the node's enter/exit events instead of colliding — it never blocks movement. Reposition it
with trigger.moveTo (a static body doesn't follow later node moves).
enter / exit events
Contact begin/end (solid vs solid) and sensor overlap begin/end (trigger) both arrive as the
node's enter/exit events, delivered to both nodes with the other node as the argument:
hero.addEventListener('enter', other => {}) // touched a wall, or entered a trigger
hero.addEventListener('exit', other => {})CharacterController2D — platformer movement
node.aspect(CharacterController2D, {
speed?: number // horizontal move speed, world units/s; default 200
jumpSpeed?: number // jump take-off speed, world units/s; default 500
groundProbe?: number // extra ray distance below the feet for the grounded check; default 6
footOffset?: number // node origin → feet distance; default = half the sprite height
})
node.controller.move(dir: number) // horizontal intent in [-1, 1]; STICKY — set 0 to stop
node.controller.jump() // queued; consumed next frame if grounded
node.controller.grounded // true while standing on something (updated each frame)Built on velocity control: each frame it sets the body's horizontal velocity to
dir * speed and lets Box2D own the vertical (gravity, jumping, landing, wall/floor response).
grounded comes from a short downward raycast from the feet.
If the node lacks them, attaching the controller auto-adds a Shape2D (auto box from the sprite)
and a Physics2D with { motion: 'dynamic', fixedRotation: true }.
setLoop(() => {
hero.controller.move((Input.key('ArrowRight') ? 1 : 0) - (Input.key('ArrowLeft') ? 1 : 0))
if (Input.key('Space')) hero.controller.jump()
})Queries (static)
Physics2D.raycast(from: Vec2Like, to: Vec2Like): RayHit | null
// RayHit = { node: Node2D | null, point: Vec2, normal: Vec2, fraction: number /* 0..1 along the ray */ }
Physics2D.overlapPoint(p: Vec2Like): Node2D | nullraycast returns the closest body hit by the segment; overlapPoint the topmost body —
solid or sensor — whose shape contains the world point.
Pointer picking
A node with a Shape2D plus a Physics2D or Trigger2D is pointer-hittable: taps hit-test
via Physics2D.overlapPoint, so the node receives click / touchstart events. See
pointer events & gestures. For picking without bodies, use
scene.pick() (sprite bounds).
Pitfalls
// ✗ full extents — box takes HALF-extents
hero.aspect(Shape2D, { box: [24, 40] }) // that's a 48×80 box
// ✓ half the sprite size
hero.aspect(Shape2D, { box: [12, 20] })
// ✗ configuring after bodies exist — resets the world
ground.aspect(Shape2D, {}).aspect(Physics2D, { motion: 'static' })
Physics2D.configure({ gravity: [0, -500] })
// ✓ configure first, then create bodies
// ✗ moving a dynamic body by writing the transform
setLoop(() => { hero.x += 2 })
// ✓ drive the body
hero.physics.velocity = [120, hero.physics.velocity.y]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