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.
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:
| Option | Default | Description |
|---|---|---|
gravity | { x: 0, y: -9.81 } | World gravity vector |
allowSleep | true | Allow idle bodies to sleep for performance |
velocityIterations | 8 | Solver iterations for velocity constraints (more = stable stacks) |
positionIterations | 3 | Solver iterations for position correction (more = less penetration) |
warmStarting | true | Use previous frame's impulses as starting point |
continuousPhysics | true | Enable continuous collision detection (CCD) |
Stepping the Simulation
Call step() each frame with a fixed timestep for deterministic behavior:
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:
// 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 });| Type | Responds to Forces | Collides With | Use Case |
|---|---|---|---|
static | No | dynamic, kinematic | Ground, walls, static level geometry |
dynamic | Yes | all types | Players, projectiles, physics objects |
kinematic | No (user-driven) | dynamic | Moving platforms, doors, elevators |
Shapes and Fixtures
Shapes define collision geometry. Attach them to bodies as fixtures with physical material properties:
Circle
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
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)
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)
// 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:
// 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:
// 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:
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:
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:
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:
import {
OnCollisionBegin,
OnCollisionEnd,
OnTriggerEnter,
OnTriggerExit,
} from '@web-engine-dev/physics2d';Collision Filtering
Control which fixtures collide using category bits and mask bits:
// 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:
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:
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):
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:
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:
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)
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)
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
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:
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:
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
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:
// 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
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
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
// 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:
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:
pnpm add @web-engine-dev/physics2d-rapier
# or for 3D:
pnpm add @web-engine-dev/physics3d-rapierThe adapters implement the same interfaces, so switching requires only changing the import:
// 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:
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:
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
- Physics Concepts -- Architecture overview and interface-first design
- ECS -- Scheduling physics systems within the ECS
- Physics 2D Package -- Full 2D API reference
- Physics 3D Package -- Full 3D API reference