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: CbType.ANY_CONSTRAINT does not work for BREAK or SLEEP events.
You must create a dedicated CbType and assign it to the constraint.
// WRONG — listener never fires
space.listeners.add(
new ConstraintListener(CbEvent.BREAK, CbType.ANY_CONSTRAINT, handler),
);
// CORRECT — create and assign a custom tag
const breakableTag = new CbType();
joint.cbTypes.add(breakableTag);
space.listeners.add(
new ConstraintListener(CbEvent.BREAK, breakableTag, handler),
);
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
});
Web Worker physics is slower than main thread
Possible causes:
-
postMessagefallback — IfSharedArrayBufferis 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 -
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:
- Is
fluidEnabled = trueon the fluid shape? - Did you set
fluidProperties? - Is the fluid body's type
STATIC(orKINEMATIC)? - 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);
"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
Simulation is slow / how to find the bottleneck
Use the built-in performance profiler to identify which phase is eating time.
import { PerformanceOverlay } from "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.




