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
- Character Controllers, pre-built character movement
- AI & NPCs, physics-aware enemy navigation
- 2D Development, physics in tilemapped worlds