npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@phalanx-engine/physics

v0.3.0

Published

Deterministic fixed-point physics engine for Phalanx Engine - spatial hashing, collision detection, and resolution

Readme

@phalanx-engine/physics

Deterministic physics for lockstep games. Positions, velocities, and collision response are fixed-point, so two peers that run the same ticks separate the same bodies by the same amounts.

The pipeline is two-dimensional on XZ. Height is a velocity the integrator applies on Y. A building hit uses a swept-segment query against boxes you supply.

pnpm add @phalanx-engine/physics

Peers: @phalanx-engine/ecs, @phalanx-engine/math. Games import this package through @phalanx-engine/game/physics.

Quick start

PhysicsWorld.attach(world) is the setup. Call it before world.start().

import { GameWorld, type ITickFrameProvider } from '@phalanx-engine/ecs';
import { FP, FPVector3 } from '@phalanx-engine/math';
import {
  PhysicsWorld,
  attachPhysicsBody,
  attachTransform,
  setVelocity,
} from '@phalanx-engine/physics';

const tickFrameProvider: ITickFrameProvider = {
  onTick: () => () => {},
  onFrame: () => () => {},
};

const world = new GameWorld({ tickFrameProvider });
const physics = new PhysicsWorld({
  gridCellSize: FP.FromFloat(8),
  subSteps: 3,
  tickRate: 20,
});
physics.attach(world);
world.start();

const entity = world.createEntity();
attachTransform(entity, FPVector3.FromFloat(0, 0, 0));
attachPhysicsBody(entity, { radius: FP.FromFloat(1) });
setVelocity(entity, FP.FromFloat(2), FP._0, FP._0);

const hits: number[] = [];
physics.onCollision((event) => {
  hits.push(event.entityA, event.entityB);
});

Subscribe to onCollision and onBoundsExit after world.start(). Earlier throws. TRIGGER_ENTER and TRIGGER_EXIT are reserved and are not emitted.

What attach wires

One call, in this order:

  1. Registers Transform, PhysicsBody, and transient Interpolation. The two SoA components are saved and included in configHash. Interpolation is display samples: not saved, not hashed.
  2. Provides the Physics service (defineService), so world.context.getService(Physics) is this PhysicsWorld.
  3. Adds GravitySystem, then PhysicsSystem (after: 'GravitySystem'), to the physics tick phase.
  4. Adds InterpolationSystem to the interpolation frame phase.
  5. Registers save section physics.broadphase (version 1).

A second attach of the same world does nothing. A different world throws. Do not register Transform, PhysicsBody, or Interpolation again yourself.

Movement that writes velocity runs in input, before the physics phase. A system that must see the integrated positions uses after: 'PhysicsSystem' or the late phase.

PhysicsSystem.step() is for tests and tools. A running world advances physics on the tick.

Helpers

Wrapper classes are gone. These functions take the entity. Reads return shared scratch. Copy the values before the next read.

| Function | Role | | ------------------------------------------------------------- | ----------------------------- | | attachTransform(entity, position?, rotation?) | SoA position and quaternion | | readFpPosition(entity) / writeFpPosition(entity, value) | Authoritative position | | readFpRotation / writeFpRotation / readYaw / writeYaw | Orientation | | attachPhysicsBody(entity, config) | Velocity, radius, mass, flags | | readVelocity(entity) / setVelocity(entity, x, y, z) | Body velocity | | addVelocity / stopVelocity | Accumulate or zero | | attachInterpolation(entity, position?, rotation?) | Render samples |

readBroadphase attaches Interpolation to every entity that has Transform and PhysicsBody, seeded from the transform. InterpolationSystem.afterTick snapshots, then captures. beforeFrame interpolates. Tick code must not branch on Interpolation.

const position = readFpPosition(entity);
const copy = { x: position.x, y: position.y, z: position.z };
writeFpPosition(entity, copy);
setVelocity(entity, FP.FromFloat(1), FP._0, FP._0);

PhysicsBody fields: velocityX/Y/Z, radius, mass, restitution, friction, isStatic, ignorePhysics, useGravity, gravityMultiplier. There is no display-float cache on the body.

attachPhysicsBody(entity, {
  radius: FP.FromFloat(0.4),
  mass: FP._1,
  isStatic: false,
  restitution: FP.FromFloat(0.5),
  friction: FP._0,
  useGravity: false,
  gravityMultiplier: FP._1,
});

A missing SoA row (indexOf(entityId) === -1) returns early. Invalid configuration throws from the constructor.

For a hot loop, read the arrays by the row from world.query:

for (const [, row] of world.query(PhysicsBody, Transform)) {
  const vx = physicsStore.arrays.velocityX[row];
  if (vx === undefined) continue;
  physicsStore.arrays.velocityX[row] = vx;
}

Collisions-only mode

integrate: false separates overlaps and leaves movement to the game. One pass per tick. Sub-steps, friction, maxVelocity, and worldBounds do not apply.

const physics = new PhysicsWorld({
  integrate: false,
  collisionResponse: 'push',
  pushStrength: FP.FromFloat(15),
});

'push' writes a velocity of overlap × pushStrength and does not integrate it into position. 'impulse' keeps restitution. GravitySystem still runs and writes velocity.y. The push velocity is not cleared, so isSettled means something only if the game zeros velocity itself.

Collision response

collisionResponse defaults to 'push'.

| Mode | What happens | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | 'push' | Positional separation plus a mass-weighted push scaled by pushStrength. Stable for crowds and characters. | | 'impulse' | Momentum along the contact normal, j = (1 + e) · vₙ / (1/mₐ + 1/m_b), skipped when the bodies are already separating. XZ only. velocityY is left alone. |

e is config.restitution when set, otherwise the average of the two bodies. Static bodies are infinite mass.

const physics = new PhysicsWorld({
  collisionResponse: 'impulse',
  restitution: FP.FromFloat(0.85),
});

setCollisionFilter((entityA, entityB) => boolean) skips a pair when it returns false.

Gravity

Off by default (gravity is 0). Opt in for arcs: shells, grenades, anything that leaves the ground.

GravitySystem accelerates. For useGravity bodies it does velocityY -= gravity * tickDt * gravityMultiplier and never writes position. PhysicsSystem is the only integrator, on all three axes. gravityAxis is 'y'. 'x' and 'z' throw, because those axes already belong to the integrator.

maxVelocity and worldBounds stay on XZ. A body with velocityY = 0 does not move on Y.

const physics = new PhysicsWorld({
  gravity: FP.FromFloat(9.81),
  tickRate: 20,
});

attachPhysicsBody(shell, {
  radius: FP.FromFloat(0.2),
  mass: FP._1,
  friction: FP._1,
  useGravity: true,
  gravityMultiplier: FP.FromFloat(2),
});

physics.applyImpulse3D(
  shell.id,
  FP.FromFloat(6),
  FP.FromFloat(8),
  FP.FromFloat(-3)
);

applyImpulse(entityId, vx, vz) sets XZ velocity directly and does not divide by mass. applyImpulse3D applies momentum: velocity = impulse / mass on all three axes. Mass <= 0 is unchanged. Both clear ignorePhysics.

isSettled(threshold?) is true when every non-static, non-ignored body is under the threshold (default FP.FromFloat(0.01)), counting X, Y, and Z.

Gravity runs once per world tick. The physics step may sub-step. That same velocityY is integrated on every sub-step.

Raycasts

The body pipeline does not collide on Y. For a point moving through a box, pass the previous position, the current position, and the boxes:

const hit = physics.raycastSegment(prevPos, curPos, buildingBoxes);
if (hit) {
  detonateAt(hit.point, hit.normal);
}

hit.t is 0 at prev and 1 at cur. hit.point is the fixed-point impact. hit.normal is the outward face normal. A segment that starts inside a box returns t = 0. The query is a linear scan of the boxes you pass. The moving body is a point; inflate the box by the radius when you need a margin. segmentVsAABB tests one box.

getInterpolatedTransform(entityId) is for frame systems, after InterpolationSystem.beforeFrame. It returns float position and a float quaternion rotation. Those floats must not flow back into the simulation.

Events

const PhysicsEvents = {
  COLLISION: 'physics:collision',
  TRIGGER_ENTER: 'physics:trigger:enter',
  TRIGGER_EXIT: 'physics:trigger:exit',
  BOUNDS_EXIT: 'physics:bounds:exit',
} as const;

BOUNDS_EXIT requires ejectOnBoundsExit: true. The body is marked ignored instead of clamped.

Config

interface PhysicsWorldConfig {
  gridCellSize?: FixedPoint; // default FP.FromFloat(4)
  subSteps?: number; // default 3
  tickRate?: number; // default 20
  worldBounds?: {
    minX: FixedPoint;
    minZ: FixedPoint;
    maxX: FixedPoint;
    maxZ: FixedPoint;
  };
  defaultFriction?: FixedPoint; // default FP.FromFloat(0.92)
  maxVelocity?: FixedPoint; // default FP.FromFloat(15)
  pushStrength?: FixedPoint; // default FP.FromFloat(15)
  collisionResponse?: 'push' | 'impulse'; // default 'push'
  restitution?: FixedPoint;
  integrate?: boolean; // default true
  ejectOnBoundsExit?: boolean; // default false
  settleThreshold?: FixedPoint; // default FP.FromFloat(0.01)
  gravity?: FixedPoint; // default 0
  gravityAxis?: 'x' | 'y' | 'z'; // only 'y' is accepted
}

spatialGrid is the broad-phase hash. queryPairs() and queryRadius() return reused, already-sorted buffers. Consume them before the next call.

Narrow phase covers circle/circle, circle/AABB, and AABB/AABB. A manifold is { entityA, entityB, normalX, normalZ, penetration }.

License

MIT