@rpgjs/physic
RPG Physic
Deterministic 2D top-down physics for RPG-JS games.
Use it when you need the same gameplay simulation on the server and in the browser: movement, collisions, sensors, and high-volume projectiles.
Why Use It
@rpgjs/physic is designed for server-authoritative RPG gameplay where the
same rules must run consistently on the server and, when needed, on the client.
Key strengths:
- Gameplay-first API: create players, NPCs, obstacles, sensors, and projectiles without wiring a general-purpose physics engine from scratch.
- Deterministic fixed-step simulation: use
stepFrame()with stable inputs for predictable server ticks and client prediction. - Server and browser friendly: the runtime package is ESM, has no runtime dependencies, and does not require Node.js APIs.
- Efficient broad phase by default:
SpatialHashis the recommended production default for RPG maps. - Lightweight projectiles: projectiles are plain data with raycast-based hit detection, so you do not need to create a physics entity for every arrow, bullet, or spell.
- RPG-oriented helpers: sensors, collision masks, teleports, snapshots, and movement helpers are included for common RPG server workflows.
What To Use
For most RPG-JS projects, start with this stack:
PhysicsEngineas the main entry point.createCharacter()for players and NPCs.createStaticObstacle()for walls, trees, rocks, and map blockers.createSensor()for vision, skills, aggro ranges, and area detection.stepFrame()for a fixed server tick.ProjectileSystemfor arrows, bullets, spells, and other high-volume projectiles.
Keep these for advanced or experimental work:
World,Entity, and colliders when you need low-level control.MovementManagerand movement strategies for scripted movement.RegionManager,Region,BVH, andQuadtreefor experiments and benchmarks.
The recommended production default is a single PhysicsEngine world with the
default SpatialHash broad phase.
Coordinate Model
Positions are expressed in world units.
- Entity
xandyvalues represent the entity center. speedvalues are world units per second.timeStepis expressed in seconds.1 / 60means 60 simulation ticks per second.- Rectangular hitboxes and static obstacles use
widthandheightaround the entity center.
For example, an obstacle at { x: 256, y: 128, width: 128, height: 24 } is
centered at (256, 128).
Install
npm install @rpgjs/physic
Quick Start
import { PhysicsEngine, Vector2 } from '@rpgjs/physic';
const engine = new PhysicsEngine({
timeStep: 1 / 60,
gravity: new Vector2(0, 0),
});
const hero = engine.createCharacter('hero', {
x: 100,
y: 100,
hitbox: { width: 16, height: 24 },
speed: 120,
});
engine.createStaticObstacle('tree', {
x: 180,
y: 100,
width: 32,
height: 32,
});
engine.stepFrame({
hero: 'right',
});
console.log(hero.position.x, hero.position.y);
Server Tick Loop
Run physics from a fixed tick on the server. Send inputs to the server, apply
them with stepFrame(), then broadcast only the state your game needs.
const engine = new PhysicsEngine({ timeStep: 1 / 60 });
engine.createCharacter('player-1', {
x: 100,
y: 100,
hitbox: 12,
speed: 140,
});
function tick(inputs: Record<string, 'up' | 'down' | 'left' | 'right' | 'idle'>) {
const tickId = engine.stepFrame(inputs);
return {
tick: tickId,
players: engine.getEntities().map((entity) => ({
id: entity.uuid,
x: entity.position.x,
y: entity.position.y,
})),
};
}
stepFrame() applies movement inputs, advances physics by one tick, updates
sensors, and returns the new tick number.
Server Architecture
For online games, keep the server authoritative:
- Receive player inputs.
- Store the latest input per player for the next tick.
- Call
engine.stepFrame(inputs)at a fixed interval. - Broadcast only the state your clients need.
- Let clients predict visuals locally when possible.
Prefer sending compact state:
function serializeState(engine: PhysicsEngine) {
return {
tick: engine.getTick(),
entities: engine.getEntities().map((entity) => ({
id: entity.uuid,
x: entity.position.x,
y: entity.position.y,
vx: entity.velocity.x,
vy: entity.velocity.y,
})),
};
}
Avoid sending every internal physics detail every tick. Treat the server as the source of truth and the client as a renderer/predictor.
Edge And Durable Objects
The package runtime is suitable for edge environments because it does not depend on Node.js APIs. A natural Cloudflare Durable Object model is:
1 Durable Object = 1 active map instance, room, battle, or dungeon
Good Durable Object usage:
- Create one
PhysicsEngineper active map instance. - Load static map blockers when the object starts.
- Apply inputs through RPC or WebSocket messages.
- Run ticks only while the instance is active.
- Persist the canonical game state you need to recover after eviction.
- Rebuild the engine from persisted state when the object wakes up.
Avoid:
- One global Durable Object for the whole game world.
- A permanent 60 Hz tick for empty maps.
- Relying only on in-memory engine state for important gameplay data.
- Using
RegionManageras Durable Object sharding. It is an in-memory experimental API, not distributed edge partitioning.
For large games, shard by map instance first. Sharding one continuous map across multiple Durable Objects is possible, but it requires explicit boundary management, migration rules, and cross-object interaction handling.
Characters
Use createCharacter() for players, NPCs, monsters, and moving objects.
const player = engine.createCharacter('player-1', {
x: 64,
y: 128,
hitbox: { width: 18, height: 28 },
speed: 160,
linearDamping: 0.12,
});
Supported hitboxes:
hitbox: 12
hitbox: { radius: 12 }
hitbox: { width: 16, height: 24 }
hitbox: { type: 'circle', radius: 12 }
hitbox: { type: 'box', width: 16, height: 24 }
hitbox: { type: 'capsule', radius: 8, height: 28 }
Move a character manually:
engine.moveEntity('player-1', 'left');
engine.moveEntity(player, { x: 1, y: 1 }, 220);
engine.moveEntity('player-1', 'idle');
Teleport safely:
engine.teleportEntity('player-1', { x: 320, y: 96 });
Teleporting through the engine keeps the broad phase synchronized.
Static Obstacles
Use createStaticObstacle() for map blockers.
engine.createStaticObstacle('wall-1', {
x: 256,
y: 128,
width: 128,
height: 24,
});
Static obstacles block dynamic entities and can be used by raycasts and sensors.
Sensors
Sensors detect entities without creating physical collision responses. They are useful for vision, aggro, skill ranges, interaction zones, explosions, and traps.
engine.createSensor('hero-vision', {
entity: hero,
radius: 120,
onEnter: (entities) => {
console.log('entered vision:', entities.map((entity) => entity.uuid));
},
onExit: (entities) => {
console.log('left vision:', entities.map((entity) => entity.uuid));
},
});
engine.stepFrame({ hero: 'right' });
Static sensor:
engine.createSensor('healing-zone', {
position: { x: 500, y: 300 },
radius: 48,
onEnter: healEntities,
});
Cone sensor:
engine.createSensor('guard-view', {
entity: guard,
radius: 160,
angle: 90,
direction: 'down',
limitedByWalls: true,
});
For advanced zone operations, use engine.getZoneManager().
Projectiles
Use ProjectileSystem for high-volume projectiles. Projectiles are plain data,
not full physics entities, so you can simulate many of them without adding
projectile-projectile collisions or syncing positions every tick.
import { ProjectileSystem } from '@rpgjs/physic';
const projectiles = new ProjectileSystem(engine);
projectiles.onSpawn(({ projectile }) => {
socket.broadcast('projectile:spawn', {
id: projectile.id,
ownerId: projectile.ownerId,
origin: projectile.origin,
direction: projectile.direction,
speed: projectile.speed,
range: projectile.range,
ttl: projectile.ttl,
spawnTick: projectile.spawnTick,
});
});
projectiles.onHit(({ projectile, hit }) => {
socket.broadcast('projectile:hit', {
id: projectile.id,
targetId: hit.entity.uuid,
x: hit.point.x,
y: hit.point.y,
});
});
projectiles.onDestroy(({ projectile, reason }) => {
socket.broadcast('projectile:destroy', {
id: projectile.id,
reason,
});
});
projectiles.spawn({
id: 'arrow-1',
ownerId: 'hero',
origin: hero.position,
direction: { x: 1, y: 0 },
speed: 420,
range: 640,
ttl: 1.5,
spawnTick: engine.getTick(),
});
projectiles.step(1 / 60);
For sockets, send:
spawnhitdestroy
Avoid sending every projectile position every tick. Clients can predict visuals from spawn data while the server remains authoritative for hits.
Collisions
Listen to global collision events:
engine.getEvents().onCollisionEnter((collision) => {
console.log(collision.entityA.uuid, collision.entityB.uuid);
});
Listen on a single entity:
hero.onCollisionEnter(({ other }) => {
console.log('hero touched', other.uuid);
});
Use collision masks for filtering:
const PLAYER = 0x01;
const WALL = 0x02;
engine.createCharacter('hero', {
x: 0,
y: 0,
hitbox: 12,
speed: 120,
collisionCategory: PLAYER,
collisionMask: WALL,
});
Manual Mutations
Prefer engine helpers. They keep spatial data synchronized:
engine.moveEntity('hero', 'right');
engine.teleportEntity('hero', { x: 100, y: 100 });
engine.freeze(entity);
engine.unfreeze(entity);
If you mutate an entity directly, call updateEntity() afterward:
entity.position.set(128, 96);
entity.width = 32;
entity.height = 48;
engine.updateEntity(entity);
Prediction And Reconciliation
For client prediction, use fixed ticks and snapshots.
const snapshot = engine.takeSnapshot();
engine.restoreSnapshot(serverSnapshot);
for (const input of pendingInputs) {
engine.stepFrame({ hero: input.direction });
}
Quantization can reduce floating-point drift:
const engine = new PhysicsEngine({
timeStep: 1 / 60,
positionQuantizationStep: 1 / 16,
velocityQuantizationStep: 1 / 256,
});
PredictionController and DeterministicInputBuffer are available when you
need a fuller prediction/reconciliation pipeline.
Snapshots are intentionally lightweight. takeSnapshot() stores physics state
for existing entities: position, velocity, rotation, angular velocity, sleeping
state, and tick. It does not serialize the full map, static obstacle definitions,
hitbox setup, projectiles, sensors, callbacks, movement strategies, or custom
game data. For persistence or Durable Object recovery, store your canonical game
state separately and use snapshots only as one part of the reconstruction flow.
Low-Level API
Use low-level APIs when the RPG helpers are not enough:
createEntity()for custom bodies.Worldfor direct simulation control.raycast()for line checks and targeting.sweep()for continuous collision checks.MovementManagerfor scripted movement strategies such as dash, knockback, path following, or projectile-style movement.
Example:
const entity = engine.createEntity({
uuid: 'custom-body',
position: { x: 0, y: 0 },
radius: 10,
mass: 1,
});
const hit = engine.raycast(entity.position, { x: 1, y: 0 }, 200);
Movement Strategies
Use MovementManager when a movement is not just direct player input. It is
useful for dashes, knockbacks, scripted movement, path following, ice movement,
or other temporary behaviours.
Available presets:
Dash: applies a burst of velocity in one direction for a fixed duration.Knockback: pushes a body in one direction with optional decay.LinearMove: moves a body in a constant direction.LinearRepulsion: pushes a body away from a point or source.PathFollow: moves a body along a list of waypoints.SeekAvoid: seeks a target while avoiding nearby obstacles.ProjectileMovement: moves a body like a projectile.IceMovement: simulates sliding movement, including optional entry velocity for slippery terrain.Oscillate: moves a body back and forth.CompositeMovement: combines multiple movement strategies.
const movement = engine.getMovementManager();
const dash = movement.dash(player, {
speed: 240,
direction: { x: 1, y: 0 },
duration: 0.15,
onComplete: () => {
engine.moveEntity(player, 'idle');
},
});
function tick() {
engine.stepWithMovements();
}
dash.finished.then(() => {
console.log('dash complete');
});
Call stepWithMovements() or call updateMovements() before step() so active
strategies update entity velocities before the physics tick.
Helper methods return a MovementHandle:
const patrol = movement.followPath(guard, {
waypoints: [
{ x: 120, y: 120 },
{ x: 360, y: 120 },
{ x: 360, y: 300 },
],
speed: 80,
loop: true,
});
if (patrol.isActive()) {
patrol.cancel();
}
Common helper options:
replace: remove existing movements on the target before adding the new one (defaults totruefor helper methods).stopOnComplete: set velocity to zero when the movement finishes.onStart: callback called on the first movement update.onComplete: callback called when the strategy finishes naturally.
Use replace: false when you intentionally want to stack strategies:
movement.dash(player, {
speed: 260,
direction: { x: 1, y: 0 },
duration: 0.12,
replace: false,
});
For slippery terrain, start ice() when an entity enters the surface and pass
the current velocity so the slide continues from the previous movement:
import type { IceMovement } from '@rpgjs/physic';
const ice = movement.ice(player, {
direction: player.velocity.lengthSquared() > 0
? player.velocity.normalize()
: { x: 1, y: 0 },
maxSpeed: 180,
acceleration: 0.35,
friction: 0.08,
initialVelocity: player.velocity,
replace: false,
});
const iceMovement = ice.strategy as IceMovement;
// While the player stays on ice:
iceMovement.setTargetDirection({ x: 1, y: 0 });
// When the input is released, the entity keeps sliding and slows down.
iceMovement.stop();
Custom Movement Strategies
Create a custom movement by implementing MovementStrategy.
import type { MovementBody, MovementStrategy } from '@rpgjs/physic';
class PatrolMovement implements MovementStrategy {
private elapsed = 0;
constructor(
private readonly speed: number,
private readonly duration: number,
) {}
update(body: MovementBody, dt: number): void {
this.elapsed += dt;
body.setVelocity({ x: this.speed, y: 0 });
}
isFinished(): boolean {
return this.elapsed >= this.duration;
}
onFinished(): void {
// Optional hook for cleanup or chaining.
}
}
const patrolDone = engine.getMovementManager().add(
player,
new PatrolMovement(80, 2),
);
update(body, dt) is called before each physics step. Use it to change the
body velocity, or translate() when you intentionally need direct movement.
dt is in seconds and should match the engine tick duration. When
isFinished() returns true, the manager removes the strategy, calls
onFinished(), triggers the onComplete option, and resolves the Promise
returned by movement.add().
Do not await movement.add() before your tick loop starts. The promise resolves
only after future calls to stepWithMovements() or updateMovements().
The low-level movement.add(entity, new MyStrategy()) API is still available
when you need to instantiate strategies directly. Prefer helper methods for
common presets and add() for custom or advanced composition.
Performance Guidelines
Performance depends more on world shape and simulation policy than on raw entity count. Benchmark with your real map, not only synthetic tests.
Recommended defaults:
- Start with one
PhysicsEngineper active map instance. - Use the default
SpatialHashbroad phase. - Prefer 20 or 30 Hz server ticks for networked games unless 60 Hz is required.
- Keep static blockers as static obstacles (
mass: 0). - Use
ProjectileSystemfor arrows, bullets, and spells instead of full physics entities. - Send projectile
spawn,hit, anddestroyevents instead of syncing every projectile position every tick. - Keep sensor counts reasonable. Cone sensors with
limitedByWallscan raycast and are more expensive than simple circular sensors. - Use
positionQuantizationStepandvelocityQuantizationStepwhen you need to reduce floating-point drift across server/client prediction.
Watch for bottlenecks:
- Too many dynamic entities colliding in the same area.
- Too many sensors updated every tick.
- Too many projectiles with long raycasts.
- Broadcasting full world state to every client.
- Running ticks for inactive or empty map instances.
Experimental APIs
These APIs are exported, but are not the recommended default path yet:
RegionManagerRegionBVHQuadtree
Use them for experiments and benchmarks. Keep PhysicsEngine without regions
and the default SpatialHash unless your own benchmark shows a clear win.
Examples
npm run example:rpg
npm run example:movement
The RPG example is a plain HTML/Canvas mini-game using the recommended APIs:
characters, static obstacles, sensors, and ProjectileSystem.
The movement example is a focused arena showing MovementManager helpers such
as dash, knockback, followPath, oscillate, seekAvoid, and ice.
Other examples in this package are intended for development and regression testing.
Commands
npm test
npm run typecheck
npm run build
npm run docs
npm run test:coverage
npm run benchmark:projectiles
Benchmark commands:
npm run benchmark
npm run benchmark:1000
npm run benchmark:10000
npm run benchmark:collisions
npm run benchmark:projectiles
npm run benchmark:regions
Current Benchmark Signal
On the current development machine, the lightweight projectile benchmark showed:
- 1k active projectiles: about
1.09ms/step - 5k active projectiles: about
5.68ms/step - 10k active projectiles: about
11.92ms/step - 0 physics entities created for projectiles
Treat these as micro-benchmarks. For production sizing, benchmark your real map, obstacle density, player count, masks, and socket interest management.
Package Shape
Main exports:
import {
PhysicsEngine,
ProjectileSystem,
Vector2,
AABB,
Entity,
World,
} from '@rpgjs/physic';
The package has no runtime dependencies.