LeCodesdocs

2D physics

Box2D-backed physics as aspects on Node2D. Four pieces, mirroring the 3D family:

  • Shape2D — pure geometry (accessor node.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): fires enter/exit, blocks nothing.
  • CharacterController2D — a platformer helper (node.controller) on top of a dynamic body.

At a glance

TypeScript
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

TypeScript
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

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

TypeScript
Physics2D.interpolation = false   // static, global; default true

Turn interpolation off to save per-frame transform writes with many moving bodies (they then advance in discrete 60 Hz steps).

Shape2D — geometry

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

  • box takes half-extents: box: [12, 20] is a 24×40 box.
  • capsule, segment, and polygon points are in node-local space and ignore offset.
  • segment is for static solid geometry only — it has no area (no density) and is never a sensor; don't use it with Trigger2D or on dynamic bodies.
Note

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

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

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

Note

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

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

TypeScript
hero.addEventListener('enter', other => {})   // touched a wall, or entered a trigger
hero.addEventListener('exit',  other => {})

CharacterController2D — platformer movement

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

TypeScript
setLoop(() => {
  hero.controller.move((Input.key('ArrowRight') ? 1 : 0) - (Input.key('ArrowLeft') ? 1 : 0))
  if (Input.key('Space')) hero.controller.jump()
})

Queries (static)

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

raycast 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

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