@daneren2005/shared-memory-physics

v1.8.0

Published

2D physics components and systems built on the shared-memory ECS.

Readme

@daneren2005/shared-memory-physics

2D physics components and systems for @daneren2005/shared-memory-ecs. Everything lives in the ECS's shared-memory blocks, so the simulation can run on a worker thread while the main thread reads positions straight off its entities.

Examples at https://daneren2005.github.io/shared-memory-physics/

The ECS (>=1.1.1) and the shared-memory primitives it is built on are peer dependencies: a game must register these components against the same copy of the ECS it builds its world with.

npm install @daneren2005/shared-memory-physics @daneren2005/shared-memory-ecs @daneren2005/shared-memory-objects

Components

| Component | Config | Serialization | Block | | --------------- | -------------------------------------------- | -------------------------- | ---------------------------------------------- | | dynamics | - | accelerationX?, accelerationY?, mass? | Float32Array - acceleration and inverse mass | | transform | width, height, angle? / radius | x, y, angle? | Float32Array – where, how big, facing | | velocity | – | velocityX?, velocityY?, damping? | Float32Array – world units per second | | body | shape?, collideCategory?, collideMask?, sensor?, continuousCollisionDetection?, blocksPath? | – | Uint32Array – what shape, what collides with | | interpolation | interpolate | – | Float32Array – where to draw it |

Entity configs are flat and shared across every component, so velocity is keyed as velocityX / velocityY rather than x / y (which already belong to the transform). Either velocity axis on its own is enough to load one, and the missing one defaults to 0.

damping is optional linear damping in fraction of speed per second: with it set, a mover started once bleeds speed off every step and coasts to a stop instead of gliding forever. It is applied implicitly as velocity / (1 + damping * seconds), so it stays stable at any step size and never reverses direction. It defaults to 0 (no damping) and must be finite and at least zero. An explicit velocity assignment (a queued queueVelocity, or writing velocityX directly) is authoritative for that step and bypasses damping until the entity coasts again.

const shot = world.loadEntity({ x: 0, y: 0, radius: 3, velocityX: 400, damping: 2 });

accelerationX / accelerationY are persistent world units per second squared. Either one (or mass) loads a dynamics component; missing acceleration axes default to 0 and mass defaults to 1. Physics uses semi-implicit Euler: it changes velocity by acceleration * seconds first, then moves by that new velocity in the same fixed step. That puts gravity on the physics backend instead of tying it to render frames:

world.loadEntity({
  x: 0, y: 0, width: 20, height: 40,
  velocityX: 0, velocityY: 0,
  accelerationY: 1600,
});

Mass is stored as inverse mass and round-trips through save; persistent acceleration does not depend on it. Mass must be finite and greater than zero, and acceleration must be finite. An entity without dynamics has effective mass 1 when a force or impulse is queued for it.

The transform is split in two. Config is what your entity template says - width and height are required, angle defaults to 0 - and a size is what loads the component at all: a config with neither a width nor a height does not get a transform. Serialization is the live state that changes as the world runs, so save() returns x / y and includes angle only when it is nonzero. An omitted angle defaults to 0 on load, while a reloaded entity gets its size back from your own template rather than the save.

x / y are the centre of the box, not a corner: the box is rotated about that point and collision projects out from it in both directions. angle is in radians counter-clockwise from the +x axis, and the physics never writes it - it is yours to set, usually from the heading, and collision reads it - but it round-trips through save so a turned entity comes back facing the same way. width / height are the unrotated size; a box with no area never overlaps anything, not even another box sharing its exact position.

interpolation is where to draw an entity, which stops being the same thing as where it is the moment physics runs less often than the screen refreshes. It is opt-in per entity (interpolate: true) and filled in by InterpolationSystem - see that section for what it costs and what it buys.

The body is what makes an entity collidable, and a size loads one just as it loads a transform - so everything with a place in the world collides with everything else until you say otherwise. All of it is defining config out of your own template, which is why save returns nothing for it: a reloaded entity gets its shape and categories back from the template rather than from the save. See Body shapes for shape and Collision filtering for the other two.

blocksPath marks a body as something pathfinding should route around - a wall or a building rather than another mover. The physics never reads it; it is a flag in the same packed word for a game's own navigation to query (body.blocksPath, or blocksPath(block) off a raw block). It defaults to false, and a body carrying it is assumed never to move, so a navigation structure can be updated only when such bodies are added or removed.

Body shapes

An entity is one of four shapes. The transform still owns its position, size and angle; a polygon additionally has a polygon component holding its outline:

| shape | Config | What it is | | ----------------- | ------------------------------------- | ------------------------------------------------------------- | | SHAPE_RECTANGLE | width, height | a width x height box turned to angle – the default | | SHAPE_CIRCLE | radius | a circle of diameter width, which angle does not affect | | SHAPE_CAPSULE | width, height, shape | a pill along its longer side, as thick as its shorter side | | SHAPE_POLYGON | vertices | a convex 3-16 vertex outline, scaled and turned by its transform |

import { SHAPE_CAPSULE } from '@daneren2005/shared-memory-physics';

world.loadEntity({ x: 0, y: 0, width: 10, height: 4 });                          // a rectangle
world.loadEntity({ x: 0, y: 0, radius: 5 });                                     // a circle
world.loadEntity({ x: 0, y: 0, width: 40, height: 10, shape: SHAPE_CAPSULE });   // a capsule
world.loadEntity({ x: 0, y: 0, vertices: [[-8, -6], [10, 0], [-8, 6]] });       // a triangle

Polygon vertices are local to the entity. They must describe a convex boundary in clockwise or counter-clockwise order; concave, collinear, non-finite, and out-of-range outlines throw during loading. Their bounding box becomes the transform's initial width and height, and the stored vertices are normalized around that box's centre. Changing the transform size therefore scales the polygon, while angle rotates it around that centre. vertices cannot be given alongside radius.

Give width and height with the vertices when the outline should sit tighter than the box the entity is sized by - a sprite with the hull drawn inside it, say. The box then stays that size, the vertices are offsets from the entity's centre rather than from their own bounds, and every one must lie inside the box (the broadphase indexes the box, so an outline poking out of it would miss contacts):

// A 40x40 sprite whose triangular hull fills only part of it; x/y and the drawn size stay the sprite's.
world.loadEntity({ x: 0, y: 0, width: 40, height: 40, vertices: [[-12, -15], [14, 0], [-12, 15]] });

radius is the same size said differently: it loads as a width and a height of the diameter, so there is still only one size in the block however the config spelled it. Giving both a radius and a width throws rather than one quietly winning. A config that says nothing about its shape is read off the size it gave - a vertices describes a polygon, radius describes a circle, and anything else a rectangle - so only capsules have to name their shape, and an unrecognised one throws rather than quietly becoming a rectangle.

A capsule lies along its longer side, caps included: a 40x10 capsule is a 30-long core along the way the entity faces with a 5 radius cap at each end, the same 10 thick all the way down, and a 10x40 one is that same pill turned a quarter, running along the entity's height instead - handy for art drawn nose-up. capsuleCoreAngle gives the angle the core lies at. One as long as it is wide is a circle.

Because a circle is a capsule whose core has no length, the three primitive shapes are an oriented core grown by a radius - a box with no radius, a point, or a segment - which leaves only three overlap tests rather than one per primitive pairing: box against box (the separating axis test), core against core, and core against box. Those fast paths remain unchanged; only a pair involving a polygon enters the polygon separating-axis test. Touching exactly still counts as not overlapping, whichever pair it is, and a shape with no area never overlaps anything. Which of the two sizes decides that depends on the shape: a circle with no height is a perfectly good circle, while a rectangle with no height is nothing at all.

contactNormal answers the other half of a contact: the unit direction one shape is pushed off the other, written into a { x, y } you pass in, and false when the two are exactly on top of each other and there is no such direction. It splits the same three ways off the same geometry, so the direction is the face that was actually hit at the angle the pair is turned to - not the nearest world axis. This is what the native bounce reflects around; a game reaching for its own response can use it the same way.

shapesOverlap and contactNormal are the allocation-free primitive-shape functions. Their polygon counterparts are polygonShapesOverlap and polygonContactNormal, which additionally take the polygon component blocks. shapeHalfWidth / shapeHalfHeight (the axis-aligned box a shape is indexed under), shapeRadius, shapeIsEmpty, capsuleHalfLength, capsuleCoreAngle, and the distance primitives underneath - segmentSegmentDistanceSquared, segmentBoxDistanceSquared and pointSegmentDistanceSquared, each of which takes an optional out vector and fills it with the gap it measured. orientedBoxesOverlap, boundsHalfWidth and boundsHalfHeight are still there and still rectangle-only.

Registering

Spread physicsRegistry into your own registry so the world allocates the memory pools for these components alongside your game specific ones. The keys are what the systems query on, so keep them as they are:

import { BaseWorld } from '@daneren2005/shared-memory-ecs';
import { physicsRegistry } from '@daneren2005/shared-memory-physics';

const registry = {
	...physicsRegistry,
	health: healthDefinition,
};
const world = new BaseWorld(registry);

const entity = world.loadEntity({ x: 0, y: 0, width: 10, height: 5, velocityX: 10, velocityY: 0, maxHealth: 100 });
console.log(entity.components.transform!.x); // 0

PhysicsSystem

PhysicsSystem moves every entity that has both a transform and a velocity, integrating optional acceleration and queued commands into velocity and then velocity into position once per run - and, once you give it an update built by createPhysicsUpdate, tells you which entities have run into each other. It is generic over your full component map, so your world passes straight in:

import { PhysicsSystem } from '@daneren2005/shared-memory-physics';

world.addSystem(new PhysicsSystem(world));

await world.init();
world.update(1000); // one second: the entity above is now at x = 10

elapsedTime is in milliseconds (what BaseWorld#update is driven with), velocity is in world units per second, and acceleration is in world units per second squared. A run covering 16ms first adds 16/1000ths of the acceleration to velocity, then moves 16/1000ths of that updated velocity.

Dynamics commands

Queue forces, impulses, or velocity assignments on the PhysicsSystem that owns the mover. A force acts for the duration of the next physics run; an impulse changes velocity once without being scaled by that run's duration. Both are divided by the entity's mass. A velocity assignment replaces only the axes it names, after acceleration, force, and impulse integration but before movement, so it is exact and independent of mass:

const physics = world.addSystem(new PhysicsSystem(world));

physics.queueForce(entity, 0, 20);       // next run: acceleration += force / mass
physics.queueImpulse(entity, 12, -40);   // next run: velocity += impulse / mass
physics.queueVelocity(entity, { velocityY: -980 }); // next run: set y without changing x

The methods also accept an entity id. Forces and impulses queued for the same entity before dispatch are summed; the last velocity assignment for each axis wins. Zero force/impulse vectors and empty velocity assignments are ignored. Non-finite values throw immediately.

Commands have a strict run boundary. A command queued before dispatch belongs to that run and is consumed once. A command queued while a worker run is in flight belongs to the following run. A command is still consumed if its entity is absent from the mover query because it was removed, filtered, scoped out, or in a skipped group; queue it again when the entity becomes eligible. In a sharded simulation, queue on the system whose mover filter owns the entity.

The same methods are available on world inside a createPhysicsUpdate collision callback, with entity ids:

onCollision(world, self, other, queries, callbacks, contact) {
  world.queueVelocity(self.entityId, {
    velocityX: contact.normalX * 480,
    velocityY: -720,
  });
}

Callback commands always target the next run and survive between runs in either backend. They combine with commands queued through PhysicsSystem: forces and impulses add together, while a newer main-thread velocity assignment replaces a callback assignment on each axis it names.

The step

PhysicsSystem runs on a fixed step of 50ms unless you say otherwise (DEFAULT_PHYSICS_STEP_MS). Collision is by a distance the whole of it, so running it 20 times a second rather than 60 is two thirds less of the frame spent on it for a simulation that is no less correct - only coarser. What that costs is smoothness, which is what InterpolationSystem is for.

new PhysicsSystem(world, { deltaBetweenRuns: 0 });   // back to every frame

The step has one real ceiling: a move is swept where it ends, so a longer step is a longer move, and a move longer than the thing in its way is thick goes clean through it. At 50ms an entity crossing 300 units a second covers 15 of them per step, so anything it must be stopped by has to be thicker than that. A game with genuinely fast entities either keeps the step short or makes its walls thick.

The system accumulates frame time and runs once it has a step's worth, so a 16ms frame runs physics on roughly every third one and hands it the whole 50ms. Nothing is lost and nothing is run twice.

PhysicsSystem also stamps each run with a tick, which is what the interpolation block is published under. A game that subclasses the system to attach per-run data of its own must call super.addDataToWorld(world):

class GamePhysicsSystem extends PhysicsSystem<Components, GameComponents, GameWorld> {
	addDataToWorld(world: GameWorld): void {
		super.addDataToWorld(world);   // without this, nothing gets a tick
		world.bounds = this.bounds;
	}
}

GameWorld extends PhysicsWorld (which is EntityWorkerSystemWorld plus that tick) rather than EntityWorkerSystemWorld directly.

A run's moves are reported back to the main thread as a single position-updated event on the system, carrying the ids of everything that moved, so anything you keep keyed off position can follow the moves rather than having to poll every block:

import { POSITION_UPDATED_EVENT } from '@daneren2005/shared-memory-physics';

const physics = world.addSystem(new PhysicsSystem(world));

physics.on(POSITION_UPDATED_EVENT, (entityIds: Array<number>) => {
	for(const eid of entityIds) {
		const transform = world.getEntityByEid(eid)?.components.transform;
		// transform.x / transform.y are where it ended up
	}
});

Nearly every entity moves on nearly every run, so this is the one thing a physics system must not report an entity at a time: an event object apiece in the worker, cloned across the boundary, then a lookup and an emit apiece on the main thread costs more, at a few thousand entities, than moving them did. One array of ids costs one listener call for the whole run.

No position travels with the id, because there is no need: the transform is a SharedArrayBuffer block, so the position the worker wrote is already on the main thread by the time the event lands - read it off the entity (or straight off the block). The move is not reported as a property change either, so a listener on component-property-updated never hears about positions.

An entity that did not move is not in the list, so a world of still entities stays quiet - and so does one held against something it cannot move past.

A run only reports at all when something is listening. The bill for this event is paid in the worker - an id per moved entity pushed into the run's event array, and that array structured-cloned back across the boundary - so PhysicsSystem checks per run whether it has a listener and tells the update not to bother when it does not. That matters most for a game rendering from interpolation, which wants nothing to do with an event that fires at the physics rate: it is the rate that game is drawing around. Nothing has to be configured for it, but reportMoves: true forces it on for a listener attached somewhere the system cannot see it, and reportMoves: false turns it off regardless.

The move is written with addAtomicFloat32 rather than a plain +=, so a game system on another thread can add to the same position in the same instant without either move being lost.

Running off the main thread

Bundlers need the new Worker(new URL(...)) call written out in your own source, so the worker entry file lives in your game and is handed to the system through getWorker:

// physics.worker.ts
import { createEntitySystemWorker } from '@daneren2005/shared-memory-ecs';
import { physicsUpdate } from '@daneren2005/shared-memory-physics';

createEntitySystemWorker(self, physicsUpdate);
world.addSystem(new PhysicsSystem(world, {
	getWorker: () => new Worker(new URL('./physics.worker.ts', import.meta.url), { type: 'module' }),
	// Optional: run at a fixed timestep instead of every frame.
	deltaBetweenRuns: 1000 / 60,
}));

Without a getWorker (or where Web Workers / SharedArrayBuffer are unavailable) the exact same physicsUpdate runs in-process on the main thread instead.

Interpolation

A 50ms physics step against a 16ms frame means the transform only changes on one frame in three, and an entity drawn straight off it visibly stutters. InterpolationSystem fills in a render position that changes every frame instead, by consuming published simulation time while advancing toward the last coherent target:

render += (target - render) * (frameDuration / remainingDuration)

Normally every position it writes lies on the latest simulation segment. If worker scheduling lets several publications overtake rendering, it coalesces the unfinished path from the current render position to the latest transform while preserving all published simulated time. That can cut across intermediate turns whose segments were overwritten, but it never extrapolates past the simulation and never teleports to a replacement segment's start. The cost is at least one step of latency - plus any time the worker itself falls behind.

import { InterpolationSystem, PhysicsSystem } from '@daneren2005/shared-memory-physics';

world.addSystem(new PhysicsSystem<Components>(world, { getWorker }));
world.addSystem(new InterpolationSystem<Components>(world));

world.loadEntity({ x: 0, y: 0, width: 10, height: 10, velocityX: 100, interpolate: true });

It takes no configuration. Physics publishes the transform, step duration, cumulative simulated time, and a tick stamp. Rendering caches each coherent target and its timing. If it catches physics mid-write, it continues consuming the cached segment until another complete snapshot is available.

A worker publishes a run entity by entity while the main thread renders, so one frame can find some entities of a run at its new step and the rest at the old one - two ships flying in formation would then drift apart on screen. Each entity therefore keeps the publication it replaced, and InterpolationSystem snapshots every PhysicsSystem's committed tick once per frame: a publication newer than its run's commit is drawn from the previous one until the whole run has landed. Both systems must share the same world object for this to apply. A game that retunes deltaBetweenRuns mid-flight is followed with nothing told to this system.

Then draw from interpolation instead of transform, keeping the size and facing where they have always been:

const { x, y } = entity.components.interpolation ?? entity.components.transform!;
const { width, height, angle } = entity.components.transform!;

Draw from it every frame, not from position-updated. That event fires once per physics step, which is exactly the cadence interpolation exists to hide - a sprite moved by it would be as choppy as one reading the transform. Once nothing is listening to it, PhysicsSystem stops reporting moves at all and the whole event costs nothing; see the note above.

Only rendering should read it. Range checks, targeting, AI steering, "can I build here" and anything you persist all want transform - the real position. The render position is a function of local frame timing, so two machines drawing the same simulation at 144Hz and 30Hz hold different numbers in it at the same tick; anything that branches on it stops being deterministic. Nothing in this library ever reads it back, which is what keeps the simulation itself unaffected.

A slow physics run costs latency and nothing else

This is the property the whole thing is for, and it is worth being explicit about because it is easy to build something that looks right and does not have it. If a physics run takes longer than a frame, nothing about the motion changes - it is drawn a little further behind, at exactly the same speed. A worker that consistently takes 30ms to come back costs 30ms of latency, which is not something an eye can see; only variance in how late it is can be seen at all.

That works because the pacing is driven by the steps that have landed in the block, not by the physics system's own accumulator. The accumulator resets when a run is posted, and on a worker thread that is not when its results arrive - a run posted with 14ms of leftover and taking 30ms to come back leaves a whole frame where the accumulator says "0.3 of the way into the new step" while the block still holds the one before it, so the entity is drawn 0.3 along a segment it was drawn 0.96 along last frame. Backwards, then a lurch forward, on every step.

For the same reason physics publishes both how much simulated time the latest run covered and a cumulative total. A run that comes back late can cover several steps, then be replaced by a normal step before rendering has finished drawing it. Interpolation takes the difference in cumulative time and adds it to its remaining render timeline, so no overwritten duration turns into a forward jump.

Ordering against the physics system is only a preference - after it means a step is picked up on the frame it happened rather than the one after, which is one frame of latency and nothing else.

What it does not need

  • Pause and timeScale are free. BaseWorld#runUpdate skips every system while paused and scales the elapsed time it hands them, so the pacing stops and slows with the simulation on its own.
  • Spawns at rest are free. The block is seeded from the entity's config, so something added between steps is drawn standing where it was put rather than sliding in from the origin. A spawn already moving is the one case that needs a hand - see startInterpolation below.
  • Velocity changes, collisions and coming to rest are all just steps the simulation took, and are drawn as they happened.

What it does need: snapEntity

A teleport and a long move are the same two numbers, so this is the one thing blending cannot work out for itself. Write a transform directly and the entity is drawn sliding the whole way there over the next step:

import { snapEntity } from '@daneren2005/shared-memory-physics';

entity.components.transform!.x = 500;
snapEntity(entity);   // drawn at 500 on the very next frame

A moving spawn: startInterpolation

Blending has only one position for a brand-new entity - where it spawned - so it holds there until the first physics step gives it a second, up to a whole step later. For anything at rest that is exactly right (the bullet above stands where it was put). For something spawned already moving - a bullet leaving a barrel - that step of stillness reads as the shot hanging in the air before it goes.

startInterpolation fixes it by opening the render at the spawn point and moving it out along the entity's own velocity at its true speed, jumping the transform forward to where that motion reaches by the time the first real step lands. So the entity is drawn leaving the spawn in its true direction from the very next frame, and the seeded segment hands straight over to the first step with no seam. How far it jumps - and how long the seeded segment lasts - is whatever is left of the current step, since that is when the first run lands:

const bullet = world.loadEntity({
  x: 0, y: 0, velocityX: 400, velocityY: 0, radius: 3, interpolate: true,
});
physics.startInterpolation(bullet);   // drawn from the spawn point and moving out, not parked there until the first step

It reads the step and the accumulator off the system, so there is nothing to pass.

The jump skips a collision sweep. It is a real move of the transform, applied without sweeping the span it covers, so the entity skips forward up to a step: it can tunnel through anything within that span of the spawn, and it runs that far ahead of where its velocity alone would have put it from then on. This is a deliberate trade for spawned projectiles, where leaving the muzzle instantly matters more than that first sweep - a shot fired into a target still lands inside it and is caught and killed by the next run's overlap test; only something thin enough to sit entirely within that jumped span is passed through uncaught. Reach for it on bullets and the like, not on something that must never skip a step of collision. Because the transform is jumped to the segment's end, the render is still never drawn ahead of the simulation.

A no-op for an entity with no velocity or no interpolation, or while the system steps every frame. Driving physics by hand rather than through PhysicsSystem? Call the underlying startSpawnInterpolation(entity, { stepMs, stepFraction, tick }) directly.

A moving death: dieAtImpact

The same lag runs the other way at the end of a life. A fast entity moves a whole step at a time and, with continuousCollisionDetection on, is killed the run its swept path first crosses what it hits - which can be a near-full stride before its step actually lands there. Remove it on the spot and the render, itself a step behind, draws it blinking out well short of the target. A bullet dies in mid-air.

dieAtImpact fixes it as the mirror of the spawn seeding: instead of callbacks.entityDied, a collision callback calls it to end the dying entity's current segment on the exact point it struck (CollisionBroadphase.contactPoint, the same entry fraction the sweep already refines) and defer the kill by one run - the one extra step the render needs to play that last stride onto the impact before the entity is gone:

onCollision(world, self, other, queries, callbacks, contact) {
  // `self` (a fast sensor bullet) just swept through `other`.  Draw it reaching `other`, then die - rather than
  // vanishing a stride short.
  world.dieAtImpact(self, other);

  // Gameplay still happens now: the hit lands this run, only the visual removal waits.
  callbacks.emitEntityEvent(other.entityId, 'hit');
}

Either side of the pair can be the one to kill - dieAtImpact(dying, other) takes whichever carried the fatal role, working out that entity's own approach from its velocity. The struck entity is marked with a runtime dying bit (BODY_DYING_FLAG) that leaves it out of the broadphase at once, so nothing runs into it again while it plays out, and it is removed at the top of its next update. The bit lives in shared memory, so any worker stepping the entity honours it. Nothing clears it - a dying entity is gone a run later.

dieAtImpact is set on the world every run by createPhysicsUpdate; keep a callbacks.entityDied fallback if you call onCollision by hand without a preRun. It moves position only - raise your own hit or score event on the spot, as above.

The cost

The renderer retains the last coherent target while physics publishes a new step, so an overlapping read can keep advancing without freezing a frame.

68 bytes per interpolated entity, ten publication writes per entity per physics step, and a per-frame pass of one lerp per entity on the main thread - order 0.2ms at 10,000 entities. An entity without the component pays nothing at all. forceMainThread: false plus a getWorker moves the pass to a worker for worlds large enough that it shows up in a profile, at the price of the render position being one frame stale; because consumers only ever read interpolation.x, that is a constructor flag rather than a migration.

Physics marks the tick dirty before changing timing and position, then publishes the completed tick with a release store. The reader accepts a new snapshot only when its tick reads match and its run has committed; otherwise it reads the previous publication, and failing that continues toward the cached target.

Collisions

createPhysicsUpdate builds an update whose entities notice each other: every move is swept against whatever is in front of it, so nothing is ever moved into anything else, and a callback of yours is told what each entity ran into. The callback runs wherever the update runs - on the worker thread, in the same pass as the movement - so it cannot be handed to PhysicsSystem at construction time: functions do not survive postMessage. It reaches the worker by being baked into a module that both the worker file and the main thread import.

The callback is optional and the sweep is not. createPhysicsUpdate() with nothing asked of it at all is movement that comes to rest against walls, terrain and other units and says nothing about any of it:

// Stopped by everything, responds to nothing.
export const gamePhysicsUpdate = createPhysicsUpdate<Components>();

Movement that walks straight through the world is the bare physicsUpdate instead, which is also the only one that costs nothing per entity for the collidable query.

// game-physics-update.ts - imported by *both* the worker file and the system below
import {
	createPhysicsUpdate,
	VELOCITY_Y_INDEX,
	type PhysicsUpdateComponents,
} from '@daneren2005/shared-memory-physics';
import { HEALTH_INDEX } from './health-component';

// The blocks the update touches: the two physics needs, plus your own.
export type GameUpdateComponents = PhysicsUpdateComponents & { health?: Float32Array };

export const gamePhysicsUpdate = createPhysicsUpdate<Components, GameUpdateComponents>({
	// Extra components to send along to the callback, on top of the transform and velocity.
	optional: ['health'],
	onCollision(world, self, other, queries, callbacks, contact) {
		// `self` just moved into `other`. Throw it away from the contact on the next run.
		world.queueImpulse(self.entityId, contact.normalX * 40, contact.normalY * 40);

		// ...and take a bite out of the thing it ran into, if that is something with health.
		const health = other.components.health;
		if(health && (health[HEALTH_INDEX] -= 1) <= 0) {
			// The worker has blocks, not entities, so a death goes back to the main thread as an event.
			callbacks.entityDied(other.entityId);
		}
	},
});
// physics.worker.ts
import { createEntitySystemWorker } from '@daneren2005/shared-memory-ecs';
import { gamePhysicsUpdate } from './game-physics-update';

createEntitySystemWorker(self, gamePhysicsUpdate);
world.addSystem(new PhysicsSystem<Components, GameUpdateComponents>(world, {
	updateFunction: gamePhysicsUpdate,
	getWorker: () => new Worker(new URL('./physics.worker.ts', import.meta.url), { type: 'module' }),
}));

createPhysicsUpdate stamps what it needs onto the function it returns, so optional and the collidable query come across with updateFunction and never have to be repeated on the system.

The final contact argument carries the unit normal at first touch as normalX and normalY. It points from other toward self; negate both values when applying it to other. This is the same shape-accurate direction used by native bounce, including rotated rectangles, circles, capsules, and polygons. A continuous sensor that crossed an entire target gets the normal where it entered, not from wherever its step ended after passing through. Exactly coincident shapes have no unique direction and report (0, 0).

onCollision(world, self, other, queries, callbacks, contact) {
  // Screen coordinates grow downwards, so a normal pointing up means self landed on other.
  if (contact.normalY < -0.5) {
    self.components.velocity[VELOCITY_Y_INDEX] = 0;
  }
}

Once per pair, per run. Both sides of a contact find it - one by sweeping into it, the other by overlapping it on its own turn - and the callback fires once, for whichever got there first. self is that entity, with every block the system asked for; other is the one it hit, where only transform is guaranteed. The other side gets no call of its own, so a callback that only touches self leaves it untouched: apply damage, tag a kill, or raise an event for both entities in the one call. Which of the two is self follows update order and is not something to depend on. The normal follows that same ordering and always points away from other. An entity with no velocity is never self, because the system never moves it, but it is still found as other.

Writes go straight into shared memory, so a callback can bounce an entity by flipping its velocity, or push a value another thread also touches with the atomics from @daneren2005/shared-memory-objects. To reach past the two entities in front of it - to credit a third for a kill, say - a callback can walk queries[COLLIDABLE_QUERY], the full collidable list. Anything that has to happen on the main thread goes through callbacks: entityDied, entityComponentChanged, createEntity, and emitEntityEvent for an event of your own - the same one the move above reports itself through. To kill a fast mover on impact and still have it drawn reaching what it hit, call world.dieAtImpact(dying, other) in place of entityDied - see dieAtImpact.

For a dynamics response that does not touch raw blocks, the callback world has queueForce, queueImpulse, and queueVelocity. They take an entity id and retain the command inside whichever backend is running, including a worker. A callback happens after this run's move, so its command is integrated at the start of the next run; deferring it also makes the outcome independent of which side of the pair updated first.

What collides. Everything with a transform and a body, not only the entities the system moves, so a ship can run into a station that has no velocity of its own. Shapes that only just touch do not count as overlapping, and one with no area never collides at all.

Stopping at the edge

An entity is never moved into another one. Before anything is written to the transform, the move is swept against everything along its path, and the entity is put down on the edge of whatever is in the way - then onCollision runs for it, exactly as it would if the two had ended up overlapping.

This is every update createPhysicsUpdate builds, with or without a callback: where an entity is allowed to end up is physics' decision, and what the game does about the contact is a separate one.

// A ship at x = 0 and a station at x = 30, both 10 wide, with a velocity that asks for 25 in this run.
world.update(1000);
console.log(ship.components.transform!.x); // ~20, their edges touching - not 25, and never inside it

The move is one decision over everything it lands on rather than one per entity, so a mover ending up on top of two units comes to rest against the near one however the two happened to come up, and only the near one is reported as a collision - the far one was never reached. An entity wedged between two at the same moment gets a call for each.

Two details worth knowing:

  • An entity that was already inside another one is let through. Something you spawned on top of it, or another system pushed it into, cannot be what this move ran into, and blocking on it would pin the entity there for good. It still gets its onCollision call for the overlap.
  • An entity with nowhere left to go stays exactly where it is rather than creeping the last fraction forwards each run, so it reports no position change while it pushes - but it does keep reporting the collision for as long as it keeps pushing.

By default, the sweep changes position but leaves velocity alone for an entity without bounciness, preserving the original behavior for games that implement their own response. Set stopVelocityOnContact: true on createPhysicsUpdate to opt into a non-bouncing solid response: physics removes only the velocity component pointing into the contact normal, so bodies stop naturally against floors, ceilings, walls, and slopes while keeping their motion along the surface.

export const gamePhysicsUpdate = createPhysicsUpdate<Components>({
  stopVelocityOnContact: true,
});

This applies to both sides of each solid contact when they have velocity. Sensors still pass through, and an entity with a bounciness component keeps its configured bounce instead; bounciness: 0 remains equivalent to removing the inward velocity component.

What it costs. A move is checked where it ends, so a clear one - which is nearly every move - is a single shape test against each thing near where it lands, and nothing more. Only a move that is genuinely stopping somewhere pays for working out where: contact is then halved in on rather than solved, since three shapes at any rotation to one another have no single formula for when a pair first touches, while the overlap test the narrowphase already has answers it for any pair anywhere. The position an entity is put down at is always one that tested clear, so it is genuinely outside everything - within about 1e-4 world units of touching.

The trade for that is a move long enough to carry an entity clean past something is not stopped by it: by the time the move is out there is nothing left to land on. That takes a single run covering more ground than the thing in the way is thick, so at any normal frame rate it does not arise - a ship crossing 100 units a second moves 1.6 of them per frame at 60fps. A fixed deltaBetweenRuns puts a ceiling on it if your game has anything fast enough to care.

Continuous collision detection lifts that ceiling for the bodies that need it, without slowing down the ones that do not. Set continuousCollisionDetection: true on a body and its own move is tested along the whole path it swept this run rather than only where the step ends - so a bullet that steps clean past a target still comes to rest against it (or, if it is a sensor, still reports the pass through it). It is one extra swept-shape test per candidate and only for that body: every other body stays on the single end-of-move test. Each shape sweeps to a single covering shape - a rectangle to an oriented box spanning start to end, a circle or capsule to a capsule down its path - chosen to be a superset of the ground actually covered, so a contact is reported early at worst, never missed. A continuous body comes straight to rest at its first contact and does not corner-slide, which is the behaviour a fast thing wants anyway. Reach for it on the small, fast things that tunnel - bullets, thrown weapons - and leave it off everything else.

Collision filtering

Every body collides as a collideCategory and with a collideMask, both plain 32-bit fields. The categories are yours to name - this library only ever reads them as bits - and a pair collides when each side's mask accepts the other's category:

// Your own categories, in your own game.
const GROUND = 1 << 0;
const AIR = 1 << 1;
const PROJECTILE = 1 << 2;

world.loadEntity({ x: 0, y: 0, width: 10, height: 10, collideCategory: GROUND, collideMask: GROUND | PROJECTILE });
world.loadEntity({ x: 0, y: 0, width: 10, height: 10, collideCategory: AIR, collideMask: AIR | PROJECTILE });
// A ranged attack the ground unit fires, which can hit either of them.
world.loadEntity({ x: 0, y: 0, width: 2, height: 2, collideCategory: PROJECTILE, collideMask: GROUND | AIR });

The ground unit and the air unit pass straight through each other; the attack hits both.

The test is symmetric - both sides have to accept each other - so a pair either collides or does not regardless of which one moved into which. That matters because collisions are reported per entity as each one moves: a rule that depended on the direction would make the outcome depend on the order the system happened to move them in. The thing to remember is that the unit above has to name PROJECTILE in its own mask; a projectile naming GROUND is not on its own enough. A one-sided response - an attack that hurts a unit without the unit hurting it back - is something your onCollision decides by looking at other, not something the two entities disagree about having touched.

collideCategory defaults to 1 and collideMask to every bit, so a world that sets neither behaves as it did before categories existed. Either collideCategory: 0 or collideMask: 0 opts an entity out of collisions entirely, in both directions. canCollide(categoryA, maskA, categoryB, maskB) is exported if you want to run the same test yourself.

Both fields live in the shared block, so a system on any thread can write them and the next run picks the change up - a unit that takes off becomes an air unit without anything being re-sent to the worker.

The broadphase is bucketed by category rather than being one tree over everything, so a projectile that can only hit units never walks the tree the terrain is in.

How it is found. preRun builds a Flatbush R-tree over every collidable entity as it stands at the top of the run. That is the only moment they all agree on, so each entity's box is grown by however far its velocity could carry it before the run is out, keeping the tree a true superset of what can really collide - grown both ways rather than along the heading, since a callback can turn an entity around before it has had its own move. Then each entity, as it moves, searches the trees its mask accepts with where it has actually ended up, and every candidate that gets past the masks goes through the full shape test. CollisionBroadphase is exported if you want the tree itself, and the geometry is all reachable on its own - see the end of Body shapes.

One thing to know about the ordering. Entities move one at a time, and the narrowphase reads live positions - so an entity that has not had its own move yet is still tested where it started. Two entities closing head on will usually collide off the second one's move, when both are in their new places, rather than off both. The pair is never missed, just attributed to the side that moved last. The same ordering is why the entity that moves first gets the clear road: it takes its whole move, and the second one is the one that comes to rest against it.

Scoping a system to a group

Categories decide which kinds of body collide; scope decides which world they collide in. A game that runs several independent groups at once - solar systems around the same origin, floors of a dungeon - gives each group its own PhysicsSystem with a scope, and each one then only ever sees, moves, or collides against the entities that pass it:

world.addSystem(new PhysicsSystem<Components>(world, {
	updateFunction: gamePhysicsUpdate,
	scope: entity => entity.components.solarSystem?.solarSystemId === solId,
}));

Unlike a category rule - which every body in the world is still weighed against - a scoped-out body never enters this system's broadphase at all, so two groups whose boxes overlap around a shared origin cannot collide across the boundary, and the tree each run builds is only as big as its own group. scope is separate from the sharding filter (which narrows only which movers a worker steps, still sweeping the whole world so one simulation can spread across cores); an entity handed both has to pass each to be moved.

Using physicsUpdate in your own system

physicsUpdate is a plain EntityUpdateFunction, so a game that already has its own movement system can call it rather than adding a second system - the block offsets it reads are exported too:

import { physicsUpdate, TRANSFORM_X_INDEX, VELOCITY_X_INDEX } from '@daneren2005/shared-memory-physics';

Both physicsUpdate and updates built by createPhysicsUpdate integrate dynamics automatically. A custom update that replaces library movement owns that step too: request dynamics through the system's optional components and call integrateDynamics(world, entityId, components.velocity, components.dynamics) before turning velocity into movement.

CollisionBroadphase is available the same way, for a system that wants the collision detection without the movement. Build one per run from a list of { entityId, components } and how many seconds the run covers (that is what sizes the room left for movement), then ask it what any given entity is overlapping:

import { CollisionBroadphase, COLLIDABLE_QUERY } from '@daneren2005/shared-memory-physics';

const broadphase = new CollisionBroadphase(
  queries[COLLIDABLE_QUERY],
  world.elapsedTime / 1000,
  world.dynamicsCommands,
);
broadphase.forEachOverlapping({ entityId, components }, other => { /* ... */ });

The third argument is optional, but a custom update that applies queued commands in the same run must pass it so the index leaves enough travel room for the resulting velocity.

sweep is the other half, for a system that moves things itself and wants the same stopping at the edge. Hand it the move you were about to make and it says how much of it is available, along with whatever the rest of it would have run into:

const { fraction, blocking } = broadphase.sweep({ entityId, components }, moveX, moveY);
transform[TRANSFORM_X_INDEX] += moveX * fraction;
transform[TRANSFORM_Y_INDEX] += moveY * fraction;

blocking is what the entity came to rest against, which forEachOverlapping will not report from that resting place: the two are touching rather than through each other.

Both honour a body's continuousCollisionDetection flag. sweep picks it up on its own - a continuous body's move is swept along its whole path. forEachOverlapping needs the move handed to it, since it runs after the entity has already been put down: forEachOverlapping(self, handle, moveX, moveY) tests the path a continuous body just swept, while the two-argument form (and every non-continuous body) tests only where it now stands.

Spatial queries

PhysicalWorld keeps every transformed entity in a live, shared SharedSpatialMap. The physics worker updates the entry immediately after it writes a final position, while entity add/remove and transform/body component changes are tracked on the main thread. That makes the same index available for occasional main-thread checks such as picking an entity under the mouse, and for worker systems that would otherwise rebuild an index for only a few queries:

import { PhysicalWorld, physicsRegistry } from '@daneren2005/shared-memory-physics';

const world = new PhysicalWorld(physicsRegistry, {
  spatial: { gridSize: 50, maxEntities: 100_000 },
});

const clicked = world.findNearestSpatial(mouseX, mouseY, 0);
const nearby = world.searchSpatialAround(x, y, 100, 100);

searchSpatial, searchSpatialAround, findNearestSpatial, and findNearbySpatial return live entities and accept entity filters. Shape-aware axis-aligned bounds are stored: rotated rectangles and capsules occupy the box around their actual outline. A direct transform/body edit outside the physics update must be followed by world.updateSpatialEntity(entity). A custom physics wrapper that changes a transform after calling the library update can call updateSpatialMap(workerWorld, entityId, components) instead.

For absolute placement from a worker update or an in-process update, setEntityPosition writes both coordinates and refreshes the shared index immediately on the calling thread:

import { setEntityPosition } from '@daneren2005/shared-memory-physics';

setEntityPosition(workerWorld, { entityId, components }, { x: 300, y: 400 });

It accepts raw query/update blocks, ignores entities without a transform, and uses the optional body for shape-aware bounds. Each coordinate is stored atomically; the position and index update are not one transaction. It performs no collision sweep and does not change velocity or interpolation. For an interpolated teleport, reset the render state separately with snapEntity on the entity wrapper. Without a spatial map in the run world, it only writes the transform. Share the map with custom workers as described below.

Games with registered ECS wrapper classes can pass their entity union as PhysicalWorld's second generic. The world's entity map, lifecycle callbacks, and spatial-query results then retain that union instead of widening to BaseEntity.

PhysicsSystem requires a PhysicalWorld (or another BaseWorld that implements PhysicalWorldSource) and automatically shares its map with the physics worker. Another worker system can receive the same map by calling addPhysicalWorldData(sourceWorld, runWorld) from its system's addDataToWorld, then getSpatialMap(runWorld) in its update. The worker world must come from @daneren2005/shared-memory-ecs 1.5.1 or newer so it carries the shared heap used to reconstruct the map handle.

The live map is intentionally not the collision or bulk-query hot path. CollisionBroadphase and SpatialIndex still use Flatbush snapshots: packing one immutable R-tree per run is faster when that run will make a large number of queries. Use the live map for continuous availability and sparse queries; use SpatialIndex for query-heavy systems such as targeting every entity every tick.

SpatialIndex is the same R-tree the collision broadphase is built on, without collide categories or shape tests. Build one from anything shaped like a query result and it reads the transform (and the body, to know which outline that transform describes) off each entity. Only the transform is needed, so it indexes anything with a place in the world rather than only what collides:

import { SpatialIndex } from '@daneren2005/shared-memory-physics';

const index = new SpatialIndex(queries.collidable);

Nothing reads the blocks after that, so the index is a snapshot of where everything was when it was built - build one per run, in preRun, and let every entity in that run search it.

| Query | Answers | | -------------------------------------------------- | ---------------------------------------------------- | | search(minX, minY, maxX, maxY, filter?) | everything whose box reaches into that one | | searchAround(x, y, reachX, reachY, filter?) | the same, written from the middle out | | findNearest(x, y, maxDistance?, filter?) | the closest one, or undefined | | findNearby(x, y, maxResults, maxDistance?, filter?) | the closest maxResults, nearest first |

Every one of them takes a filter, run per candidate the tree turns up, and hands it the whole entity - { entityId, components } - so it can read back any block that came along with the query. That is where the things the index cannot know go: the searcher's own id, which side something is on, whether it is worth shooting at.

const target = index.findNearest(x, y, 150, other => other.entityId !== entityId && isEnemy(other));

findNearest walks the tree in distance order rather than searching a box and sorting what comes back, so ask it directly rather than building the sort yourself - it settles as soon as the nearest box is reached, and maxDistance stops it walking the rest of the world when the answer is that there is nobody. Distance is measured to an entity's box, not to its centre, so a large target is as near as its nearest edge and maxDistance reads as a range past the hull.

Examples

Three runnable examples live in examples/, rendered with Phaser (a dev dependency - it is not part of the library) and driven by plain HTML controls beside the canvas:

npm start          # http://127.0.0.1:8080
  • Bouncing circles - circles with a random position and heading, reflected off each other and off the walls by the native bounce, which mirrors the velocity about the contact normal.
  • Bouncing rectangles - the same, on boxes of random width and height, where the normal is the face the two are least through each other on rather than the line between their centres.
  • Walking into a wall - one unit between two boxes, turned around on a timer so it presses into each of them in turn. It starts on a deliberately long 100ms physics step, so turning interpolate rendering off is a direct before-and-after on the same running scene: the same simulation, drawn six frames at a time instead of one.

Every page has a physics step slider (starting at the library's own 50ms) and an interpolate rendering toggle, so the trade the interpolation section describes can be looked at rather than reasoned about.

The examples import the library as @daneren2005/shared-memory-physics, aliased at the source next door, so the code reads the way a game's would and editing a system hot-reloads the running page. npm start also serves the two cross-origin isolation headers SharedArrayBuffer needs, so physics genuinely runs on a worker there; where a browser will not hand one out, the ECS runs the same update in-process instead and the Run physics in a worker toggle says so.

The walls the examples bounce off are ordinary entities - a transform and a body, no velocity - so they are stopped against by the same sweep that stops anything against anything.

Every push to dev or production publishes them to GitHub Pages, both to the same place - so the site shows whichever branch pushed last, which is what you want while the examples are changing faster than releases are cut. See .github/workflows/pages.yml for the one-time repository setting it needs, and for the one line to drop when it should go back to publishing releases only.

Building

npm install
npm run build            # emits dist/ (js + d.ts)
npm run build:examples   # emits examples/dist/
npm run type-check
npm test