Skip to content

Physics Guides

This guide walks through practical physics simulation with the @web-engine-dev/physics2d and @web-engine-dev/physics3d packages. For the conceptual overview and architecture, see Core Concepts: Physics.

Creating a 2D Physics World

The PhysicsWorld2D class is the top-level simulation container. It manages bodies, joints, broadphase collision detection, and the constraint solver.

typescript
import { PhysicsWorld2D } from '@web-engine-dev/physics2d';

const physicsWorld = new PhysicsWorld2D({
  gravity: { x: 0, y: -9.81 },
  allowSleep: true,
  velocityIterations: 8,
  positionIterations: 3,
});

Configuration Options

The PhysicsWorld2DConfig interface provides fine-grained control over the simulation:

OptionDefaultDescription
gravity{ x: 0, y: -9.81 }World gravity vector
allowSleeptrueAllow idle bodies to sleep for performance
velocityIterations8Solver iterations for velocity constraints (more = stable stacks)
positionIterations3Solver iterations for position correction (more = less penetration)
warmStartingtrueUse previous frame's impulses as starting point
continuousPhysicstrueEnable continuous collision detection (CCD)

Stepping the Simulation

Call step() each frame with a fixed timestep for deterministic behavior:

typescript
function update(deltaTime: number) {
  const fixedTimeStep = 1 / 60;
  physicsWorld.step(fixedTimeStep, 8, 3);
  physicsWorld.clearForces();
}

WARNING

Always use a fixed timestep (e.g. 1/60) rather than the raw delta time. Variable timesteps cause non-deterministic behavior and can destabilize stacked objects.

Body Types

Every rigid body has one of three types that determine how the physics solver treats it:

typescript
// Static: immovable, infinite mass (ground, walls)
const ground = physicsWorld.createBody({
  type: 'static',
  position: { x: 0, y: 0 },
});

// Dynamic: fully simulated (players, projectiles, crates)
const player = physicsWorld.createBody({
  type: 'dynamic',
  position: { x: 0, y: 10 },
  linearDamping: 0.1,
  angularDamping: 0.05,
  bullet: true, // Enable CCD for fast-moving objects
});

// Kinematic: user-controlled velocity (platforms, elevators)
const platform = physicsWorld.createBody({
  type: 'kinematic',
  position: { x: 5, y: 3 },
});
// Move kinematic bodies by setting velocity, not position:
platform.setLinearVelocity({ x: 2, y: 0 });
TypeResponds to ForcesCollides WithUse Case
staticNodynamic, kinematicGround, walls, static level geometry
dynamicYesall typesPlayers, projectiles, physics objects
kinematicNo (user-driven)dynamicMoving platforms, doors, elevators

Shapes and Fixtures

Shapes define collision geometry. Attach them to bodies as fixtures with physical material properties:

Circle

typescript
player.createFixture({
  shape: { type: 'circle', radius: 0.5, center: { x: 0, y: 0 } },
  density: 1.0,       // kg/m^2 -- affects mass computation
  friction: 0.3,      // 0-1 tangential resistance
  restitution: 0.2,   // 0-1 bounciness
});

Box

typescript
ground.createFixture({
  shape: { type: 'box', width: 20, height: 1 },
  density: 0, // Static bodies typically use zero density
});

// Rotated box
crate.createFixture({
  shape: {
    type: 'box',
    width: 1, height: 1,
    center: { x: 0, y: 0.5 },
    angle: Math.PI / 4,
  },
  density: 0.5,
});

Polygon (Convex, Max 8 Vertices)

typescript
body.createFixture({
  shape: {
    type: 'polygon',
    vertices: [
      { x: 0, y: 1 },
      { x: -1, y: -1 },
      { x: 1, y: -1 },
    ],
  },
  density: 1.0,
});

Edge and Chain (Terrain)

typescript
// Single edge segment
wall.createFixture({
  shape: {
    type: 'edge',
    vertex1: { x: 0, y: 0 },
    vertex2: { x: 10, y: 0 },
  },
});

// Connected chain of edges for terrain
terrain.createFixture({
  shape: {
    type: 'chain',
    vertices: [
      { x: 0, y: 0 },
      { x: 5, y: 2 },
      { x: 10, y: 1 },
      { x: 15, y: 0 },
    ],
    loop: false,
  },
});

Sensors

Set isSensor: true to detect overlaps without generating a physical collision response:

typescript
// Trigger zone that detects when the player enters
trigger.createFixture({
  shape: { type: 'circle', radius: 3 },
  isSensor: true,
});

Forces and Impulses

Forces are applied over time (use every frame), while impulses produce instant velocity changes:

typescript
// Continuous thrust (apply each frame)
body.applyForceToCenter({ x: 0, y: 100 });

// Force at an off-center point (creates torque)
body.applyForce(
  { x: 100, y: 0 },                        // force vector
  body.getWorldPoint({ x: 0, y: 1 }),       // world point of application
);

// Continuous rotation torque
body.applyTorque(50);

// Instant velocity change (jump, explosion)
body.applyLinearImpulseToCenter({ x: 0, y: 50 });

// Impulse at a specific point
body.applyLinearImpulse(
  { x: 10, y: 0 },
  body.getWorldPoint({ x: 0, y: 0.5 }),
);

// Instant angular velocity change
body.applyAngularImpulse(5);

TIP

Call physicsWorld.clearForces() after each step to reset accumulated forces. Impulses are applied immediately and do not accumulate.

Collision Events

Contact Listener

Register a contact listener on the physics world to respond to collisions:

typescript
physicsWorld.setContactListener({
  beginContact(contact) {
    const bodyA = contact.fixtureA.body;
    const bodyB = contact.fixtureB.body;
    console.log(`Collision started: ${bodyA.id} <-> ${bodyB.id}`);
  },

  endContact(contact) {
    // Collision ended -- clean up effects, stop sounds, etc.
  },

  preSolve(contact, oldManifold) {
    // Called before the solver -- modify or disable the contact.
    // Useful for one-way platforms and conveyor belts.
  },

  postSolve(contact, impulse) {
    // React to collision intensity after solving
    const totalImpulse = impulse.normalImpulses.reduce((a, b) => a + b, 0);
    if (totalImpulse > 10) {
      // Play impact sound, spawn particles, apply damage
    }
  },
});

One-Way Platforms

Use preSolve to let objects pass through platforms from below:

typescript
preSolve(contact, _oldManifold) {
  const worldManifold = contact.getWorldManifold();
  // Only collide if the contact normal points upward
  if (worldManifold.normal.y < 0.5) {
    contact.setEnabled(false);
  }
}

Conveyor Belts

Apply a tangent speed in preSolve to move objects along a surface:

typescript
preSolve(contact, _oldManifold) {
  // Check if one fixture is the conveyor belt
  if (contact.fixtureA.userData === 'conveyor') {
    contact.setTangentSpeed(5.0);
  }
}

ECS Collision Events

The physics package also exports ECS event definitions for integration with the ECS scheduler:

typescript
import {
  OnCollisionBegin,
  OnCollisionEnd,
  OnTriggerEnter,
  OnTriggerExit,
} from '@web-engine-dev/physics2d';

Collision Filtering

Control which fixtures collide using category bits and mask bits:

typescript
// Define collision categories
const GROUND    = 0x0001;
const PLAYER    = 0x0002;
const ENEMY     = 0x0004;
const BULLET    = 0x0008;

// Player collides with ground and enemies, not own bullets
player.createFixture({
  shape: { type: 'circle', radius: 0.5 },
  density: 1.0,
  filter: {
    categoryBits: PLAYER,
    maskBits: GROUND | ENEMY,
    groupIndex: 0,
  },
});

// Enemy collides with ground, player, and bullets
enemy.createFixture({
  shape: { type: 'box', width: 1, height: 1 },
  density: 1.0,
  filter: {
    categoryBits: ENEMY,
    maskBits: GROUND | PLAYER | BULLET,
    groupIndex: 0,
  },
});

Two fixtures A and B collide if both masks allow it: (A.categoryBits & B.maskBits) !== 0 AND (B.categoryBits & A.maskBits) !== 0.

The groupIndex provides a shortcut: a positive value forces fixtures in the same group to always collide; a negative value forces them to never collide. A groupIndex of 0 defers to the category/mask bits.

Joints

Joints constrain two bodies relative to each other. Create them with physicsWorld.createJoint().

Revolute (Hinge)

A single pivot point with optional angle limits and a motor:

typescript
const hinge = physicsWorld.createJoint({
  type: 'revolute',
  bodyA: wall,
  bodyB: door,
  localAnchorA: { x: 2, y: 1 },
  localAnchorB: { x: 0, y: 1 },
  enableLimit: true,
  lowerAngle: 0,
  upperAngle: Math.PI / 2,
  enableMotor: true,
  motorSpeed: 2,
  maxMotorTorque: 100,
});

Distance (Spring)

Maintains a distance between two anchor points, with optional spring behavior:

typescript
const spring = physicsWorld.createJoint({
  type: 'distance',
  bodyA: anchor,
  bodyB: pendulum,
  localAnchorA: { x: 0, y: 0 },
  localAnchorB: { x: 0, y: 0 },
  length: 5,
  stiffness: 10,
  damping: 0.5,
});

Wheel (Vehicle Suspension)

Combines a revolute joint (wheel spin) with a prismatic joint (suspension travel):

typescript
const suspension = physicsWorld.createJoint({
  type: 'wheel',
  bodyA: chassis,
  bodyB: wheel,
  localAnchorA: { x: 1.5, y: -0.5 },
  localAnchorB: { x: 0, y: 0 },
  localAxisA: { x: 0, y: 1 }, // Suspension direction
  enableMotor: true,
  motorSpeed: 20,
  maxMotorTorque: 50,
  stiffness: 30,
  damping: 0.7,
});

Mouse (Drag-to-Target)

Drags a body toward a target point. Ideal for click-to-drag interaction:

typescript
const mouseJoint = physicsWorld.createJoint({
  type: 'mouse',
  bodyA: groundBody,       // Static anchor
  bodyB: draggedBody,
  target: { x: mouseX, y: mouseY },
  maxForce: 1000 * draggedBody.massData.mass,
  stiffness: 5,
  damping: 0.7,
});

// Update the target each frame while dragging:
// mouseJoint.setTarget({ x: mouseX, y: mouseY });

Weld (Rigid Connection)

Locks two bodies together rigidly:

typescript
physicsWorld.createJoint({
  type: 'weld',
  bodyA: hull,
  bodyB: turret,
  localAnchorA: { x: 0, y: 0.5 },
  localAnchorB: { x: 0, y: -0.5 },
  referenceAngle: 0,
});

Raycasting and Spatial Queries

Raycast (First Hit)

typescript
const start = { x: 0, y: 5 };
const end = { x: 20, y: 5 };

const hit = physicsWorld.rayCastFirst(start, end);
if (hit) {
  console.log('Hit body:', hit.fixture.body.id);
  console.log('Hit point:', hit.point);
  console.log('Surface normal:', hit.normal);
  console.log('Fraction along ray:', hit.fraction);
}

Raycast (All Hits)

typescript
const hits = physicsWorld.rayCastAll(start, end);
for (const hit of hits) {
  console.log(`Hit at ${hit.point.x}, ${hit.point.y}`);
}

Custom Raycast with Filtering

typescript
physicsWorld.rayCast((fixture, point, normal, fraction) => {
  if (fixture.isSensor) return 1;   // Skip sensors, continue full ray
  if (fixture.body.type === 'static') return fraction; // Clip ray here
  return 0;                          // Stop immediately
}, start, end);

Return values: 0 = stop, fraction = clip ray to this point, 1 = continue full length.

AABB Query

Find all fixtures overlapping an axis-aligned bounding box:

typescript
const aabb = { min: { x: 0, y: 0 }, max: { x: 10, y: 10 } };
const found: Array<{ body: number }> = [];

physicsWorld.queryAABB(aabb, (fixture) => {
  found.push({ body: fixture.body.id });
  return true; // Return true to continue, false to stop early
});

Point Query

Find all fixtures containing a specific point:

typescript
const results = physicsWorld.queryPoint({ x: 5, y: 5 });
for (const result of results) {
  console.log('Overlapping body:', result.body.id);
}

3D Physics

The @web-engine-dev/physics3d package mirrors the 2D API for three-dimensional simulations.

Creating a 3D Physics World

typescript
import { PhysicsWorld3D } from '@web-engine-dev/physics3d';

const world3d = new PhysicsWorld3D({
  gravity: { x: 0, y: -9.81, z: 0 },
  allowSleep: true,
  solverIterations: 10,
});

Bodies and Colliders

3D bodies use colliders (instead of fixtures) to define collision geometry:

typescript
// Dynamic body with sphere collider
const ball = world3d.createBody({
  type: 'dynamic',
  position: { x: 0, y: 10, z: 0 },
  continuousCollisionDetection: true,
});
ball.addCollider({
  shape: { type: 'sphere', radius: 0.5 },
  material: { friction: 0.5, restitution: 0.3 },
});

// Static ground with box collider
const ground = world3d.createBody({
  type: 'static',
  position: { x: 0, y: 0, z: 0 },
});
ground.addCollider({
  shape: { type: 'box', halfExtents: { x: 50, y: 0.5, z: 50 } },
});

// Character capsule
const character = world3d.createBody({
  type: 'dynamic',
  position: { x: 0, y: 5, z: 0 },
  fixedRotation: true,
});
character.addCollider({
  shape: { type: 'capsule', radius: 0.3, height: 1.2, axis: 'y' },
  material: { friction: 0.8, restitution: 0 },
});

3D Collision Filtering

typescript
const PLAYER     = 0x0001;
const ENEMY      = 0x0002;
const PROJECTILE = 0x0004;

character.addCollider({
  shape: { type: 'capsule', radius: 0.3, height: 1.2, axis: 'y' },
  filter: { group: PLAYER, mask: ENEMY | PROJECTILE },
});

Two colliders A and B collide if: (A.group & B.mask) !== 0 AND (B.group & A.mask) !== 0.

3D Raycasting

typescript
const hit = world3d.raycast(
  { x: 0, y: 10, z: 0 },   // origin
  { x: 0, y: -1, z: 0 },   // direction (normalized)
  { maxDistance: 100, includeTriggers: false },
);

if (hit) {
  console.log('Distance:', hit.distance);
  console.log('Contact point:', hit.point);
  console.log('Surface normal:', hit.normal);
  console.log('Hit body:', hit.body.id);
}

3D Joints

typescript
// Hinge joint for a door
world3d.createJoint({
  type: 'hinge',
  bodyA: frame,
  bodyB: door,
  pivot: { x: -1, y: 1, z: 0 },
  axis: { x: 0, y: 1, z: 0 },
  enableLimit: true,
  lowerLimit: 0,
  upperLimit: Math.PI / 2,
});

// Ball-socket joint for ragdoll shoulder
world3d.createJoint({
  type: 'ballSocket',
  bodyA: torso,
  bodyB: upperArm,
  pivot: { x: 0.5, y: 1.4, z: 0 },
  enableSwingLimit: true,
  maxSwingAngle: Math.PI / 3,
});

Physics Materials (3D)

Preset material factories for common surfaces:

typescript
import {
  createDefaultMaterial3D,    // friction: 0.5, restitution: 0.0
  createBouncyMaterial3D,     // friction: 0.3, restitution: 0.9
  createSlipperyMaterial3D,   // friction: 0.02, restitution: 0.1
  createRubberMaterial3D,     // friction: 1.0, restitution: 0.8
} from '@web-engine-dev/physics3d';

Rapier Adapter Setup

For production applications, use the Rapier adapter packages which provide high-performance WASM-compiled physics via Rapier:

bash
pnpm add @web-engine-dev/physics2d-rapier
# or for 3D:
pnpm add @web-engine-dev/physics3d-rapier

The adapters implement the same interfaces, so switching requires only changing the import:

typescript
// Reference implementation (built-in)
import { PhysicsWorld2D } from '@web-engine-dev/physics2d';

// Production adapter (Rapier-backed WASM)
import { PhysicsWorld2D } from '@web-engine-dev/physics2d-rapier';

All game code that depends on the interface types continues to work without changes.

ECS Integration

Physics state integrates with the ECS through resource descriptors:

typescript
import { PhysicsWorld2D, PhysicsWorld2DResource, PhysicsConfigResource }
  from '@web-engine-dev/physics2d';

// Create the physics world
const physicsWorld = new PhysicsWorld2D({
  gravity: { x: 0, y: -9.81 },
});

// Insert as ECS resources
world.insertResource(PhysicsWorld2DResource, physicsWorld);
world.insertResource(PhysicsConfigResource, {
  gravity: { x: 0, y: -9.81 },
  velocityIterations: 8,
  positionIterations: 3,
});

// Access in systems
function physicsStepSystem() {
  const physics = world.getResource(PhysicsWorld2DResource);
  if (physics) {
    physics.step(1 / 60);
    physics.clearForces();
  }
}

The Physics2DEntityMappingResource provides bidirectional mapping between ECS entity IDs and physics body handles:

typescript
import { Physics2DEntityMappingResource } from '@web-engine-dev/physics2d';

const mapping = world.getResource(Physics2DEntityMappingResource);
if (mapping) {
  const bodyId = mapping.entityToBody.get(entityId);
  const entityId = mapping.bodyToEntity.get(bodyId);
}

Physics systems typically run in the FixedUpdate schedule for deterministic behavior regardless of frame rate.

Units and Scale

Both packages use MKS (meters, kilograms, seconds):

  • Object sizes: 0.1 to 10 meters
  • Gravity: { x: 0, y: -9.81 } (2D) or { x: 0, y: -9.81, z: 0 } (3D)
  • Density: kg/m^2 (2D) or kg/m^3 (3D)

Using real-world scale produces the most stable simulation. Very small (< 0.1m) or very large (> 100m) objects can cause numerical instability.

Next Steps

Proprietary software. All rights reserved.