Skip to content

Physics

Web Engine Dev provides both 2D (@web-engine-dev/physics2d) and 3D (@web-engine-dev/physics3d) physics using the Rapier engine under the hood. The API is interface-first: swap the backend without changing game code.

Setup

typescript
import { createEngine } from '@web-engine-dev/engine';
import { createPhysics2DPlugin } from '@web-engine-dev/engine';

const engine = await createEngine({ canvas, width: 1280, height: 720 });
engine.addPlugin(
  createPhysics2DPlugin({
    config: {
      gravity: { x: 0, y: 980 }, // pixels/s²: typical 2D side-scroller
      fixedTimestep: 1 / 60,
    },
  })
);

Physics Components

typescript
import { RigidBody2D, Collider2D, PhysicsMaterial2D } from '@web-engine-dev/physics2d';

// A dynamic rigidbody (affected by gravity and forces)
world.insert(playerEntity, RigidBody2D, {
  type: 'dynamic',
  mass: 1,
  gravityScale: 1,
  linearDamping: 0.1,
  angularDamping: 0.5,
  fixedRotation: true, // prevent tipping for platformers
});

// A box collider
world.insert(playerEntity, Collider2D, {
  shape: 'box',
  width: 28,
  height: 44,
  offsetY: 0,
  friction: 0.5,
  restitution: 0.0, // bounciness
  isTrigger: false,
  collisionLayers: ['player'],
  collisionMask: ['ground', 'enemy', 'pickup'],
});

// Static body (doesn't move: use for terrain)
world.insert(platformEntity, RigidBody2D, { type: 'static' });
world.insert(platformEntity, Collider2D, {
  shape: 'box',
  width: 128,
  height: 24,
  friction: 0.8,
});

// Kinematic body (moved by code, not physics)
world.insert(movingPlatformEntity, RigidBody2D, { type: 'kinematic' });

Collision Events

typescript
import {
  CollisionEnterEvent,
  CollisionExitEvent,
  TriggerEnterEvent,
} from '@web-engine-dev/physics2d';

function CollisionSystem(world: World): void {
  // Physical collisions
  for (const { a, b, normal, impulse } of world.eventReader(CollisionEnterEvent).read()) {
    // Identify which entity is which
    if (world.has(a, Player) && world.has(b, Enemy)) {
      takeDamage(world, a, world.get(b, EnemyData).damage);
    }

    // Play landing sound based on impulse strength
    if (world.has(a, Player) && impulse > 100) {
      world.eventWriter(PlaySoundEvent).send({ clip: 'footstep-land', volume: impulse / 500 });
    }
  }

  // Trigger overlaps (isTrigger: true)
  for (const { a, b } of world.eventReader(TriggerEnterEvent).read()) {
    if (world.has(b, DeathZone)) {
      world.commands().spawnPrefab('RespawnEffect', { [Position]: world.get(a, Position) });
      respawnPlayer(world, a);
    }
  }
}

Applying Forces & Impulses

Access physics bodies through the PhysicsWorld2DResource resource descriptor:

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

function PlayerJumpSystem(world: World): void {
  const physics = world.getResource(PhysicsWorld2DResource);
  const jumpAction = inputManager.getAction('jump');

  const q = world.query().with(Player, Grounded).build();
  for (const result of world.run(q)) {
    if (jumpAction?.type === 'button' && jumpAction.justPressed) {
      physics.applyImpulse(result.entity, { x: 0, y: -600 });
      world.remove(result.entity, Grounded);
    }
  }
}

function ExplosionSystem(world: World): void {
  const physics = world.getResource(PhysicsWorld2DResource);

  for (const { position, radius, force } of world.eventReader(ExplosionEvent).read()) {
    // Apply radial force to all nearby dynamic bodies
    physics.applyRadialImpulse(position, radius, force);
  }
}

Raycasting & Shape Queries

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

function GroundCheckSystem(world: World): void {
  const physics = world.getResource(PhysicsWorld2DResource);
  const cmd = world.commands();

  const q = world.query().with(Position, Player).build();
  for (const result of world.run(q)) {
    const [pos] = result.components;
    const entity = result.entity;
    // Raycast downward to check for ground
    const hit = physics.raycast(
      { x: pos.x, y: pos.y }, // origin
      { x: 0, y: 1 }, // direction (down)
      { maxDistance: 26, layers: ['ground', 'platform'] }
    );

    if (hit && !world.has(entity, Grounded)) {
      world.insert(entity, Grounded, {});
    } else if (!hit && world.has(entity, Grounded)) {
      world.remove(entity, Grounded);
    }
  }
}

// Circle overlap query: find all enemies within radius
function SplashDamageSystem(world: World): void {
  const physics = world.getResource(PhysicsWorld2DResource);

  for (const { position, radius, damage } of world.eventReader(SplashEvent).read()) {
    const hits = physics.overlapCircle(position, radius, { layers: ['enemy'] });

    for (const hitEntity of hits) {
      world.eventWriter(DamageEvent).send({ target: hitEntity, amount: damage });
    }
  }
}

Character Controller

Use the built-in character controller for precise platformer movement without fighting the physics engine:

typescript
import { CharacterController2D } from '@web-engine-dev/character';

world.insert(playerEntity, CharacterController2D, {
  slopeLimit: 45, // max angle in degrees to walk up
  stepHeight: 8, // max step height to auto-climb
  skinWidth: 0.02, // collision skin (prevents tunnelling)
  coyoteTime: 0.1, // seconds of "late jump" forgiveness
  jumpBuffer: 0.1, // seconds of "early jump" buffering
});

function CharacterMovementSystem(world: World): void {
  const input = world.getResource(InputState);
  const physics = world.getResource(PhysicsWorld2DResource);
  const { delta } = world.getResource(Time);

  const ctrlQ = world.query().with(CharacterController2D, Velocity, Player).build();
  for (const result of world.run(ctrlQ)) {
    const [ctrl, vel] = result.components;
    const entity = result.entity;

    // Horizontal movement
    const rawInput = world.getResource(InputStateResource) as InputState | undefined;
    let vx = 0;
    if (rawInput?.pressedKeys.has('ArrowLeft')) vx = -200;
    if (rawInput?.pressedKeys.has('ArrowRight')) vx = 200;

    // Jump
    let vy = vel.y + 980 * delta; // gravity
    if (input.justPressed('jump') && (ctrl.isGrounded || ctrl.coyoteTimeRemaining > 0)) {
      vy = -560; // jump velocity
    }

    // Move with the controller: it handles collisions internally
    const motion = { x: vx * delta, y: vy * delta };
    const result = physics.moveCharacter(entity, motion);

    world.insert(entity, Velocity, { x: vx, y: result.isGrounded ? 0 : vy });
  }
}

Joints & Constraints

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

const physics = world.getResource(PhysicsWorld2DResource);

// Hinge joint (door, wheel)
const hingeId = physics.createJoint({
  type: 'revolute',
  entityA: doorFrameEntity,
  entityB: doorEntity,
  anchorA: { x: 32, y: 0 }, // local space
  anchorB: { x: -32, y: 0 },
  limits: { min: 0, max: 90 }, // degrees
  motor: { targetVelocity: 0, maxForce: 500 },
});

// Spring joint
const springId = physics.createJoint({
  type: 'spring',
  entityA: ceilingEntity,
  entityB: platformEntity,
  restLength: 100,
  stiffness: 200,
  damping: 20,
});

// Remove a joint
physics.destroyJoint(hingeId);

3D Physics

The 3D API mirrors the 2D API:

typescript
import { createPhysics3DPlugin } from '@web-engine-dev/engine';
import { RigidBody3D, Collider3D } from '@web-engine-dev/physics3d';

engine.addPlugin(
  createPhysics3DPlugin({
    config: { gravity: { x: 0, y: -9.81, z: 0 } },
  })
);

world.insert(playerEntity, RigidBody3D, {
  type: 'dynamic',
  mass: 70,
  fixedRotationX: true,
  fixedRotationZ: true,
});

world.insert(playerEntity, Collider3D, {
  shape: 'capsule',
  radius: 0.4,
  height: 1.8,
  friction: 0.5,
  collisionLayers: ['player'],
  collisionMask: ['terrain', 'enemy', 'pickup'],
});

Collision Layers

Define collision layers to control what collides with what:

typescript
// In your game config
const COLLISION_LAYERS = {
  default: 0,
  player: 1,
  enemy: 2,
  bullet: 3,
  ground: 4,
  platform: 5,
  trigger: 6,
  pickup: 7,
} as const;

// Collision matrix (who collides with whom):
// - player:  ground, platform, enemy, pickup, trigger
// - enemy:   ground, platform, player, bullet
// - bullet:  enemy, ground (NOT other bullets or player bullets)
// - pickup:  player (trigger only)

Next Steps

Proprietary software. All rights reserved.