Troubleshooting Guide

Common problems, their causes, and solutions. If your issue isn't listed here, check the API Reference or open an issue on GitHub.


Bodies fall through walls / floors

Symptoms: Dynamic bodies pass through static bodies without colliding.

Cause 1: Fast body, no CCD

Small or fast bodies can skip past thin walls between simulation steps.

// Fix: enable CCD on fast-moving bodies
bullet.isBullet = true;

Cause 2: Sub-stepping too low

Even with CCD, extreme speeds or thin walls can cause tunneling.

// Fix: increase sub-steps
space.subSteps = 4; // each step(dt) runs 4 internal passes at dt/4

Cause 3: Material in wrong constructor parameter (P57 bug)

If you created a Polygon with a Material in the wrong position, the Material object was silently treated as an InteractionFilter, breaking collision.

// WRONG — Material ends up in the filter slot
new Polygon(verts, undefined, material);

// CORRECT — all of these work
new Polygon(verts, material);                    // 2-arg shorthand
new Polygon(verts, localCOM, material, filter);  // full signature
new Polygon(verts, undefined, material);         // also works after P57 fix

If you're on a version before 3.19, update to the latest.

Cause 4: Zero friction tunneling

Bodies with friction = 0 and horizontal velocity can tunnel through floors.

// Fix: use a small minimum friction
for (const s of body.shapes) {
  s.material.dynamicFriction = Math.max(s.material.dynamicFriction, 0.01);
  s.material.staticFriction = Math.max(s.material.staticFriction, 0.01);
}

Raycast returns null even though bodies exist

Cause: The broadphase hasn't indexed the shapes yet. Static bodies are only registered after the first simulation step.

// Fix: call step() at least once before raycasting
space.step(1 / 60);

const result = space.rayCast(ray);
// Now result will include static bodies

applyForce() is not a function

Cause: nape-js uses applyImpulse() instead of applyForce().

  • Impulse = instantaneous velocity change (used once)
  • Force = continuous acceleration (you'd apply it every frame)
// For a one-time push (e.g., explosion, jump):
body.applyImpulse(new Vec2(0, -5000));

// For continuous acceleration (e.g., thrust), apply impulse every frame:
function update(dt: number) {
  body.applyImpulse(new Vec2(thrustX * dt, thrustY * dt));
  space.step(dt);
}

Cannot set space on a compound member body

Cause: Bodies inside a Compound share the compound's space. You can only set .space on the root compound, not on individual member bodies.

// WRONG — throws an error
compound.bodies.at(0).space = space;

// CORRECT — set space on the compound
compound.space = space;

ConstraintListener doesn't fire for BREAK events

Cause 1: the joint never breaks. Breaking is opt-in — breakUnderForce and breakUnderError are both false by default, so a joint over its maxForce / maxError is at most clamped (and a stiff joint ignores maxForce entirely).

joint.maxForce = 5000;
joint.breakUnderForce = true; // without this, BREAK never fires

space.listeners.add(
  new ConstraintListener(CbEvent.BREAK, CbType.ANY_CONSTRAINT, (cb) => {
    // cb.constraint broke; with removeOnBreak (default) it has left the space
  }),
);

Cause 2 (nape-js ≤ 3.42.3): CbType.ANY_CONSTRAINT was never attached to constraints, so listeners on it matched nothing. Upgrade, or tag the joints with your own type:

const breakableTag = new CbType();
joint.cbTypes.add(breakableTag);
space.listeners.add(new ConstraintListener(CbEvent.BREAK, breakableTag, handler));

listener.options = cbType throws "Cannot read properties of undefined"

Cause (nape-js ≤ 3.42.4): the options setter of BodyListener / ConstraintListener and the options1 / options2 setters of InteractionListener / PreListener are typed OptionType | CbType, but only an OptionType worked. Fixed in 3.43.0 — a bare CbType is accepted, as it always was in the constructors. On older versions wrap it:

listener.options = new OptionType(cbType); // works on every version

Listener precedence seems to run backwards

Cause: the direction depends on the listener type (inherited from Haxe nape):

Listener Runs first Tie (same precedence)
BodyListener, ConstraintListener higher precedence added last
InteractionListener, PreListener lower precedence added first

When several PreListeners return a flag for the same pair, the last one called wins — so the highest-precedence PreListener has the final say. Give the listener whose flag must win the highest precedence.


"Shape::sensorEnabled cannot be set during a space step()"

Cause: the flag was changed while space.step() was running — in practice, from inside a PreListener handler, which runs mid-step. Toggling a sensor rewrites broadphase and arbiter state that the step is iterating, so the engine forbids it (as upstream nape does). The same rule applies to every setter that throws "cannot be set during a space step()".

Fix: record the change in the handler, apply it after the step:

let turnGhostIntoSensor = false;
space.listeners.add(
  new PreListener(InteractionType.COLLISION, playerTag, ghostTag, () => {
    // ghostShape.sensorEnabled = true;  ← throws here
    turnGhostIntoSensor = true; // record only
    return PreFlag.IGNORE;
  }),
);

space.step(1 / 60);
if (turnGhostIntoSensor) {
  ghostShape.sensorEnabled = true; // safe: the step is over
  turnGhostIntoSensor = false;
}

BodyListener, InteractionListener and ConstraintListener callbacks are delivered after the step, so they may change the flag directly.


Joints go limp after loading a JSON save

Cause (nape-js ≤ 3.42.4): maxForce / maxError default to Infinity, which JSON.stringify writes as null. spaceFromJSON then set the limit to null — effectively zero — and every joint stopped holding. Binary snapshots and in-memory snapshots that were never stringified were not affected.

Fixed in 3.43.0: the snapshot stores unlimited as null itself and the loader reads null back as Infinity, so saves written by older versions load correctly too. On older versions, restore the limit after loading:

const restored = spaceFromJSON(JSON.parse(json));
for (const c of restored.constraints) {
  if (!c.maxForce) c.maxForce = Infinity;
  if (!c.maxError) c.maxError = Infinity;
}

A restored space drifts away from the original

Cause (nape-js ≤ 3.42.4): engine lists insert at the head, so restoring a snapshot added bodies and constraints in reverse order. The solver is order-dependent, so with several joints or a pile of contacts the restored space and the original stepped differently. Fixed in 3.43.0: a restored space keeps the original iteration order and steps identically (same platform, and space.deterministic = true for contact-heavy scenes — see the multiplayer guide).

Also check that both sides use the same Config values — they are not part of the snapshot.

Known limitation — snapshots taken mid-contact: contact state (the accumulated contact impulses the solver warm-starts from) is not part of a snapshot either. A snapshot taken while bodies are pressing on each other — a stack still settling, a pile being pushed — restores bit-close but not bit-identical: a four-box stack drifts ~0.1 px from the original over the next 30 steps. Snapshots taken before contacts form, or while the touching bodies sleep, restore exactly. For rollback, have every peer restore from the same snapshot rather than comparing a restored copy against a live space.


Collision callbacks crash with "Cannot read properties of null (reading 'remove_arb')"

Cause (nape-js ≤ 3.43.2): with a collision InteractionListener registered, two situations generated the same END twice and crashed on the second one:

  • space.subSteps > 1 and a contact that ended during a step (a bouncing ball is enough) — the END was generated once per sub-step;
  • a body switched to BodyType.STATIC while touching a static body, then back to DYNAMIC — and no BEGIN fired for the renewed contact.

Fixed in 3.43.3. On older versions keep subSteps = 1 when you listen for collision events, and remove a body from the space instead of making it static while it rests on static geometry.


A body switched STATIC → DYNAMIC sinks into the floor for a few steps

Cause (nape-js ≤ 3.43.2): with the default DYNAMIC_AABB_TREE broadphase, a body made static kept its broadphase leaf in the dynamic tree. Made dynamic again while resting on a static body, its contact pairs were not re-checked until it had moved out of its padded bounding box, so it fell ~3 px into the floor and then snapped back. Fixed in 3.43.3.


Joints pinned to space.world break the restored space

Cause (nape-js ≤ 3.43.2): a constraint attached to space.world was saved as "no body", came back with a null body, and the restored space threw "cannot be simulated null bodies" on its first step. Separately, a space using Broadphase.SPATIAL_HASH was restored with SWEEP_AND_PRUNE. Fixed in 3.43.3 for both formats (the world is stored as body id -2); snapshots saved by older versions still contain null for those joints, so re-save them.


Constraints missing after spaceFromJSON / spaceFromBinary

  • UserConstraint: it is only saved through a UserConstraintCodec passed as options.userConstraints — to the save and the load call. See the cookbook.
  • SpringJoint (nape-js ≤ 3.42.4): not saved by either format. Fixed in 3.43.0.
  • PulleyJoint throws "cannot be simulated with null bodies" after loading (nape-js ≤ 3.42.4): only body1 / body2 were saved, so body3 / body4 came back null. Fixed in 3.43.0; reassign body3 / body4 yourself on older versions.
  • Every joint, from the published package (nape-js ≤ 3.42.4): the serializer told joint types apart by class name, which the minified dist/ bundle renames — so spaceToJSON / spaceToBinary saved no constraints at all outside the repo's own tests. Fixed in 3.43.0; upgrade.

"Arbiter not currently in use" from normalImpulse() / crushFactor()

Cause (nape-js ≤ 3.42.4): after two bodies separate their arbiter lingers for Config.arbiterExpirationDelay steps, inactive. body.arbiters still listed it, so every Body impulse query (normalImpulse, tangentImpulse, totalImpulse, rollingImpulse, crushFactor) threw right after a separation. Fixed in 3.43.0: body.arbiters only lists active arbiters, as in Haxe nape. On older versions, wrap the query in try/catch for a few steps after separation.


body.interactingBodies() returns nothing (or every partner)

Cause (nape-js ≤ 3.42.4): with no type it always returned an empty list, with InteractionType.FLUID it returned every partner, and depth was ignored. Fixed in 3.43.0 — it follows interaction chains up to depth hops (-1 = unlimited, 1 = direct partners), filtered by type.


shapesInAABB / bodiesInAABB miss shapes that are clearly inside

Cause (nape-js ≤ 3.42.4): the broadphase reuses one query rectangle and rescaled it from the previous query. After a tiny query far from the origin the next query searched the wrong region. Separately, DYNAMIC_AABB_TREE bodiesInAABB(…, containment = true) could report a body whose other shape sticks out. Both fixed in 3.43.0 (both inherited from Haxe nape).


A WeldJoint makes a rotation-only body spin faster and faster

Cause (nape-js ≤ 3.42.4): when neither welded body can translate (e.g. allowMovement = false against a static body) the solver inverted a singular matrix; with an anchor away from the body's centre the spin grew without bound. Fixed in 3.43.0. On older versions use PivotJoint + AngleJoint.


Setting a Capsule's radius after scale() is ignored or warps it

Cause (nape-js ≤ 3.43.1): scale() rescaled the capsule, but the radius / halfLength setters compared against and rebuilt from the pre-scale values. After capsule.scale(2, 2), setting radius back to its original value did nothing, and any other value rebuilt the outline with the old halfLength while the halfLength getter reported the scaled one.

Fixed in 3.43.2. On older versions, rebuild the shape instead of resizing a scaled capsule: remove it and add new Capsule(width, height).


for (const v of polygon.localVerts) throws "is not iterable"

Cause (nape-js ≤ 3.42.4): localVerts / worldVerts were not real Vec2Lists — not iterable, and new Polygon(other.localVerts) was rejected. Fixed in 3.43.0. On older versions use for (let i = 0; i < l.length; i++) l.at(i).


Known limitations

  • GeomPoly.simpleDecomposition() on degenerate input: three edges crossing at one point, a vertex visited twice (spikes, keyholes) can throw; rarely a piece has zero area or self-crosses; an edge crossing the interior of a vertical edge can be missed — isSimple() then returns true, and simpleDecomposition() can return the self-crossing ring as a single piece. After a throw, call simpleDecomposition() only on fresh engine state (static sweep state is left behind). Clean up input (simplify, remove duplicate points) first. Inherited from Haxe nape.
  • MarchingSquares.run with bounds that are not a whole number of cells samples the last column/row past the bound; with combine = true a tiny negative value (e.g. -1e-20 float noise) next to a notch can crash. Use bounds that are a multiple of the cell size, and clamp near-zero values.

Material constructor order is confusing

The parameter order is:

Material(elasticity, dynamicFriction, staticFriction, density, rollingFriction)

Elasticity comes first. This differs from some engines where friction is first.

// Bouncy rubber ball
const rubber = new Material(
  0.9,  // elasticity (high = bouncy)
  0.8,  // dynamicFriction
  0.9,  // staticFriction
  1.2,  // density
  0.01, // rollingFriction
);

// Or use a built-in preset:
const preset = Material.rubber(); // pre-configured values

Bodies jitter or explode on startup

Cause: Bodies spawned overlapping each other. The solver pushes them apart aggressively, causing a "physics explosion."

Fix: Ensure bodies don't overlap at spawn. Leave at least 1-2 px gap, or use space.subSteps = 4 to improve solver stability.


Stacked objects are unstable / wobble

Fix 1: Increase sub-steps:

space.subSteps = 4;

Fix 2: Increase solver iterations:

// Default is step(dt, 10, 10)
space.step(1 / 60, 20, 20); // more velocity + position iterations

Fix 3: Increase friction on the shapes:

for (const s of body.shapes) {
  s.material.staticFriction = 2.0;
  s.material.dynamicFriction = 1.5;
}

Character slides on slopes / doesn't stop

Cause: Using force/impulse-based movement instead of CharacterController.

Dynamic body movement with forces inherently slides on slopes due to gravity decomposition. Use the geometric CharacterController for precise platformer movement.

import { CharacterController } from "@newkrok/nape-js";

const cc = new CharacterController(space, body, {
  maxSlopeAngle: Math.PI / 4, // 45° walkable
});

Character hangs on a wall / ledge edge while a direction key is held

Symptom: Holding left/right into a wall (or the side of a platform / one-way platform) while airborne freezes the character in place — it only falls once the key is released.

Cause: Material friction against a re-asserted push. A velocity-driven character writes e.g. vx = 210 into the wall every frame; the contact solver cancels that with a large normal impulse, and friction turns it into a tangential impulse big enough to cancel gravity for the whole step.

Fix: CharacterController handles this since the wallFriction option exists — non-ground contacts (steeper than maxSlopeAngle) get friction 0 while floors and slopes keep material friction. If you passed wallFriction: null, or drive the body yourself, either drop the option or zero friction on wall contacts in a PreListener:

new PreListener(InteractionType.COLLISION, playerTag, CbType.ANY_BODY, (cb) => {
  const arb = cb.arbiter.collisionArbiter;
  if (arb && Math.abs(arb.normal.y) < 0.7) {
    arb.dynamicFriction = 0;
    arb.staticFriction = 0;
  }
  return null; // leave accept / ignore to the engine
}).space = space;

Web Worker physics is slower than main thread

Possible causes:

  1. postMessage fallback — If SharedArrayBuffer is not available (missing COOP/COEP headers), transforms are copied via postMessage each frame. Ensure your server sends:

    Cross-Origin-Opener-Policy: same-origin
    Cross-Origin-Embedder-Policy: require-corp
    
  2. Too many bodies — Worker communication overhead per body is fixed. For < 50 bodies, main-thread physics is often faster.


Fluid buoyancy doesn't work

Checklist:

  1. Is fluidEnabled = true on the fluid shape?
  2. Did you set fluidProperties?
  3. Is the fluid body's type STATIC (or KINEMATIC)?
  4. Is the dynamic body's shape density different from the fluid density?
// Correct fluid setup
const waterShape = new Polygon(Polygon.box(300, 100));
waterShape.fluidEnabled = true; // <-- often forgotten
waterShape.fluidProperties = new FluidProperties(1.5, 3.0);
waterBody.shapes.add(waterShape);

Buoyancy flickers or vanishes when a vertex touches the surface (before nape-js 3.43.1): the fluid overlap clipper lost track when a polygon vertex lay exactly on the fluid's edge (or on a fluid circle's rim), or when two edges were collinear — e.g. a box resting flat on the water line. The fluid arbiter disappeared for that step, or got the wrong area. Fixed by replacing the clipper with an exact polygon/circle clip; upgrade if you see it.


A body struck by a very fast kinematic spinner jumps to the pivot

Known limitation. A kinematic body spinning fast enough to sweep a large arc in one step (from roughly 30 rad/s for a 200 px arm at 60 Hz) does hit what it sweeps over (CCD sub-samples its swept AABB), but the struck body can then be pulled next to the pivot instead of flying off tangentially: after the time of impact the spinner keeps turning, and the position solver resolves the contact against the now-stale edge normal with an unclamped correction.

Workarounds: set space.subSteps so the spinner turns less per step (subSteps = 4 removes it up to ~60 rad/s for that arm; faster spinners need more), cap the spinner's angular velocity, or drive the paddle as a dynamic body with a MotorJoint instead of a kinematic body.


"Cannot read property of disposed Vec2"

Cause: Using a Vec2.weak() or manually disposed vector after it's been returned to the object pool.

// WRONG — weak vectors are auto-disposed after one use
const v = Vec2.weak(10, 0);
body.applyImpulse(v);
console.log(v.x); // ERROR: disposed

// CORRECT — use Vec2.get() and dispose manually
const v = Vec2.get(10, 0);
body.applyImpulse(v);
console.log(v.x); // OK
v.dispose(); // return to pool when done

GeomPoly.cut() returns a piece that is not simple, or throws a TypeError

Both are fixed in 3.43.0; upgrade. On 3.42.4 and earlier:

Not simple: the cut line passes through a single reflex vertex that only touches the line — both of its neighbours are on the same side, and the polygon continues on the other side. The two lobes on the touching side came back as one piece pinched at that vertex. Split such pieces yourself:

// Notch from the top at (10, 10): y = 10 gives a rectangle + 2 triangles
const notch = new GeomPoly([
  new Vec2(0, 0), new Vec2(20, 0), new Vec2(20, 20), new Vec2(10, 10), new Vec2(0, 20),
]);
const pieces = notch.cut(new Vec2(-5, 10), new Vec2(25, 10));
// 3.43.0+: three simple pieces. ≤ 3.42.4: rectangle + one pinched piece.
for (let i = 0; i < pieces.length; i++) {
  const p = pieces.at(i);
  if (!p.isSimple()) p.simpleDecomposition(); // → the two triangles
}

TypeError: Cannot read properties of null: the start and end of the cut line were the same point (a click without a drag). Since 3.43.0 a zero-length line cuts nothing and returns one uncut copy, like a line that misses the polygon. On older versions, skip the cut when start equals end.


Simulation is slow / how to find the bottleneck

Use the built-in performance profiler to identify which phase is eating time.

import { PerformanceOverlay } from "@newkrok/nape-js/profiler";

// Quick visual overlay
const overlay = new PerformanceOverlay(space, { position: "top-right" });

// Or headless: enable metrics and read them programmatically
space.profilerEnabled = true;
space.step(1 / 60);
const m = space.metrics;
console.log(`broadphase: ${m.broadphaseTime}ms, narrowphase: ${m.narrowphaseTime}ms`);

Common bottlenecks and fixes:

Bottleneck Symptom Fix
Broadphase broadphaseTime high, many bodies Switch broadphase strategy (SweepAndPrune → DynamicAABBTree or SpatialHashGrid)
Narrowphase narrowphaseTime high Too many overlapping shapes — simplify geometry, use collision filtering
Velocity solver velocitySolverTime high Reduce solver iterations: space.step(dt, 8, 8) instead of default 10
Sleeping bodies low sleepingBodyCount near 0 Ensure sleep is enabled — resting bodies should auto-sleep to save CPU
Too many contacts contactCount very high Use InteractionFilter bit masks to skip unnecessary collisions

See the Cookbook — Performance Profiling recipe for full setup.


Deterministic mode doesn't produce identical results across browsers

This is expected. nape-js provides "soft" determinism — identical results on the same platform (same browser + OS + architecture). Cross-platform bit-exact IEEE 754 determinism is not possible in pure JavaScript because different JS engines handle floating-point edge cases differently.

For multiplayer, use server-authoritative architecture with periodic state sync rather than relying on cross-platform determinism. See the Multiplayer Guide.