Skip to content

Physics

Web Engine Dev provides physics simulation through an interface-first design. Abstract physics packages define the API, while adapter packages provide concrete implementations backed by physics engines like Rapier.

Architecture

The physics system follows the engine's Hexagonal Architecture pattern:

Interface packages (define the API):
  @web-engine-dev/physics2d
  @web-engine-dev/physics3d

Adapter packages (provide implementations):
  @web-engine-dev/physics2d-rapier
  @web-engine-dev/physics3d-rapier

Your game code depends on the interface packages. Swap backends by changing only the adapter -- no gameplay code needs to change.

2D Physics

The 2D physics package (@web-engine-dev/physics2d) provides rigid body dynamics, collision detection, and constraints. Its API design is inspired by Box2D.

Physics World

The physics world is the top-level container for all simulation:

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

const world = new PhysicsWorld2D({
  gravity: { x: 0, y: -9.81 },
  allowSleep: true,
});

Body Types

TypeDescriptionUse Case
staticImmovable, infinite massGround, walls, static level geometry
dynamicFully simulated, responds to forces and collisionsPlayers, projectiles, physics objects
kinematicUser-controlled velocity, not affected by forcesMoving platforms, doors, elevators
typescript
// Create a dynamic body
const player = world.createBody({
  type: 'dynamic',
  position: { x: 0, y: 10 },
  linearDamping: 0.1,
  bullet: true, // Enable CCD for fast-moving objects
});

// Create static ground
const ground = world.createBody({
  type: 'static',
  position: { x: 0, y: 0 },
});

Shapes and Fixtures

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

typescript
// Attach a circle shape
player.createFixture({
  shape: { type: 'circle', radius: 0.5 },
  density: 1.0,       // kg/m^2 -- affects mass
  friction: 0.3,      // Tangential resistance (0-1)
  restitution: 0.5,   // Bounciness (0-1)
});

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

Available 2D shape types:

ShapeDescription
circleSimple and efficient
polygonConvex only, max 8 vertices
boxConvenience for axis-aligned rectangles
edgeLine segment (one-sided or two-sided)
chainConnected edges for terrain

Forces and Impulses

MethodEffectUse Case
applyForce()Continuous acceleration (applied over time)Thrust, wind, gravity
applyLinearImpulseToCenter()Instant velocity changeExplosions, jumps
applyTorque()Continuous rotationSpinning
typescript
// Continuous force (apply every frame)
body.applyForceToCenter({ x: 0, y: 100 });

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

// Force at a point (creates torque)
body.applyForce({ x: 100, y: 0 }, body.getWorldPoint({ x: 0, y: 1 }));

Collision Events

React to collisions through the contact listener:

typescript
world.setContactListener({
  beginContact(contact) {
    const a = contact.fixtureA.body;
    const b = contact.fixtureB.body;
    // Collision started
  },

  endContact(contact) {
    // Collision ended
  },

  preSolve(contact, oldManifold) {
    // Modify contact before solving
    // Example: one-way platform
    const normal = contact.getWorldManifold().normal;
    if (normal.y < 0.5) {
      contact.setEnabled(false);
    }
  },

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

The ECS integration also provides event-based collision notifications:

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

Collision Filtering

Control which fixtures collide using category and mask bits:

typescript
body.createFixture({
  shape: { type: 'box', width: 1, height: 1 },
  filter: {
    categoryBits: 0x0002,  // What I am
    maskBits: 0x0001,      // What I collide with
    groupIndex: 0,          // Override (positive = always collide, negative = never)
  },
});

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

2D Joints

Constrain two bodies together:

JointDescriptionCommon Use
revoluteHinge/pivot pointDoors, wheels
prismaticSlider along an axisPistons, rails
distanceFixed or spring distanceRopes, chains
weldRigid connectionCompound objects
wheelRevolute + prismaticVehicle suspension
mouseDrag body to target pointClick-to-drag
frictionResist relative motionTop-down friction
motorApply forces to reach target offsetAnimated mechanisms
typescript
world.createJoint({
  type: 'revolute',
  bodyA: wheel,
  bodyB: chassis,
  localAnchorA: { x: 0, y: 0 },
  localAnchorB: { x: -1.5, y: -0.5 },
  enableMotor: true,
  motorSpeed: 10,
  maxMotorTorque: 100,
});

Raycasting and Queries

typescript
// Cast a ray and get the first hit
const hit = world.rayCastFirst(start, end);
if (hit) {
  console.log(hit.fixture, hit.point, hit.normal, hit.fraction);
}

// Cast a ray and get all hits
const hits = world.rayCastAll(start, end);

// Query all fixtures overlapping an AABB
world.queryAABB(aabb, (fixture) => {
  return true; // Return true to continue, false to stop
});

// Query all fixtures containing a point
const overlapping = world.queryPoint({ x: 5, y: 5 });

Stepping the Simulation

typescript
const fixedTimeStep = 1 / 60;

function update(deltaTime: number) {
  world.step(fixedTimeStep, 8, 3);
  // velocityIterations: 8 (more = stable stacks)
  // positionIterations: 3 (more = less penetration)
  world.clearForces();
}

3D Physics

The 3D physics package (@web-engine-dev/physics3d) mirrors the 2D API for 3D rigid body dynamics.

Creating a 3D Physics World

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

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

3D Bodies and Colliders

Bodies use the same static/dynamic/kinematic model. Shapes are attached as colliders:

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

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

3D Shape Types

ShapeDescription
sphereRadius-based sphere
boxHalf-extents per axis
capsuleRadius + height, configurable axis (x/y/z)
cylinderRadius + height
coneRadius + height
convexHullFrom vertex array
meshTriangle mesh (static bodies only)
heightfieldTerrain from height data
compoundMultiple child shapes

3D Joints

JointDescriptionCommon Use
fixedWelds two bodiesCompound objects
hingeSingle-axis rotationDoors, wheels
ballSocketFree rotation with limitsRagdoll shoulders
sliderLinear motion along axisPistons, rails
distanceMin/max distanceRopes, chains
springDistance with spring behaviorSuspension
coneCone twist constraintRagdoll spine
sixDofFull 6 degrees of freedomComplex articulations
gearLinks rotation of two hingesGear trains
motorDriven position/velocityRobots, mechanisms

3D Raycasting

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

if (hit) {
  console.log(hit.point, hit.normal, hit.distance, hit.body);
}

Physics Materials

Both 2D and 3D packages provide preset materials:

typescript
// 2D
import { createDefaultMaterial, createBouncyMaterial } from '@web-engine-dev/physics2d';

// 3D
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';

ECS Integration

Physics state is exposed as ECS resources for integration with the scheduler:

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

// Insert the physics world as a resource
world.insertResource(PhysicsWorld2DResource, physicsWorld);

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

Rapier Adapters

For production use, the Rapier adapter packages provide high-performance, Rust-compiled-to-WASM physics:

  • @web-engine-dev/physics2d-rapier -- Rapier 2D adapter
  • @web-engine-dev/physics3d-rapier -- Rapier 3D adapter

These adapters implement the same interfaces as the built-in physics packages, so switching is a matter of changing your import:

typescript
// Built-in (reference implementation)
import { PhysicsWorld3D } from '@web-engine-dev/physics3d';

// Rapier adapter (production)
import { PhysicsWorld3D } from '@web-engine-dev/physics3d-rapier';

Units

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

  • Objects should be 0.1 to 10 meters in size
  • 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 or very large objects can cause numerical instability.

Next Steps

  • ECS -- Physics components and systems run within the ECS
  • Rendering -- Visualize physics bodies with the renderer
  • Scenes -- Save and load physics configuration as part of scenes

Proprietary software. All rights reserved.