2D: RuntimeError "unreachable" in World.step() when a ball settles against ConvexPolygon bodies (deterministic repro, 25/25 crashes)
- Dominant language
- Rust
- Stars
- 5.7k
- Forks
- 387
- Avg merge
- 4d 23h
- Merged PRs (30d)
- 6
Description
## Environment
| | |
|---|---|
| Package | `@dimforge/rapier2d-compat` |
| Versions reproduced | **0.20.0** (latest stable) and canary **0.0.0-5de07a4-20260808** (different wasm hash, identical panic ticks) |
| Runtime | Node.js v24.15.0, Windows 11 x64, single-threaded |
| Settings | All defaults except `timestep = 1/60`, `lengthUnit = 1.0`, `numSolverIterations = 4`, gravity `(0, -20)` |
## Summary
`World.step()` panics with `RuntimeError: unreachable` (uncatchable — the world is unusable afterwards) when a ball rests on / grinds against `ConvexPolygon` bodies in a small settling pile. The panic occurs tens of ticks *after* the initial impact, while the pile is settling.
The repro below is ~100 lines of plain rapier calls, fully deterministic, no external assets. It runs 25 parameter variants (ball density ×0.8–1.2, five drop positions) per shape mode:
| Shape mode | Crashes |
|---|---|
| `convexHull` (sharp) | **25/25** (panic at tick 25–53) |
| `roundConvexHull`, radius 0.02 | **24/25** (tick 36–80) |
| `roundConvexHull`, radius 0.08 | **25/25** (tick 42–73) |
| same silhouette approximated with cuboids | **0/25** |
## Reproduction
`npm i @dimforge/rapier2d-compat@0.20.0`, save as `repro.cjs`, then `node repro.cjs sharp` (or `round002` / `round008`):
repro.cjs
```js
// Standalone repro: `unreachable` panic in rapier2d-compat 0.20.0 narrow phase.
// Run: node tmp-repro.cjs [sharp|round002|round008]
const RAPIER = require('@dimforge/rapier2d-compat');
const FOOT_L = [-1.5, 0, -1.1, 0, -0.55, 1.4, -0.95, 1.4];
const FOOT_R = [1.5, 0, 1.1, 0, 0.55, 1.4, 0.95, 1.4];
const LINTEL = [-1.2, 1.4, 1.2, 1.4, 1.2, 2.0, -1.2, 2.0];
const PARTS = [FOOT_L, FOOT_R, LINTEL];
const RELEASE = [
{ vx: 3, w: -1.5 },
{ vx: -3, w: 1.5 },
{ vx: 0, w: 0 },
];
function shrink(points, margin) {
const n = points.length / 2;
let cx = 0, cy = 0;
for (let i = 0; i < n; i += 1) { cx += points[i * 2]; cy += points[i * 2 + 1]; }
cx /= n; cy /= n;
const out = [];
for (let i = 0; i < n; i += 1) {
const dx = points[i * 2] - cx, dy = points[i * 2 + 1] - cy;
const len = Math.hypot(dx, dy);
const k = len > margin ? (len - margin) / len : 0;
out.push(cx + dx * k, cy + dy * k);
}
return out;
}
function hullDesc(points, radius) {
const desc = radius > 0
? RAPIER.ColliderDesc.roundConvexHull(new Float32Array(shrink(points, radius)), radius)
: RAPIER.ColliderDesc.convexHull(new Float32Array(points));
return desc.setDensity(1).setFriction(0.6).setRestitution(0);
}
function run(ballDensity, hitX, radius) {
const world = new RAPIER.World({ x: 0, y: -20 });
world.timestep = 1 / 60;
world.lengthUnit = 1.0;
world.numSolverIterations = 4;
const events = new RAPIER.EventQueue(true);
const ground = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(15, -0.5));
world.createCollider(
RAPIER.ColliderDesc.cuboid(30, 0.5).setFriction(0.6).setRestitution(0),
ground,
);
// Fixed composite "arch": two slanted feet + rectangular lintel.
const arch = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(6, 0));
const archColliders = PARTS.map((pts) =>
world.createCollider(
hullDesc(pts, radius).setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS),
arch,
),
);
const archHandles = new Set(archColliders.map((c) => c.handle));
const ball = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic().setTranslation(hitX, 3.5).setLinvel(2, -12),
);
const ballCollider = world.createCollider(
RAPIER.ColliderDesc.ball(0.25).setDensity(ballDensity).setRestitution(0),
ball,
);
let fragmented = false;
for (let i = 0; i < 600; i += 1) {
const prevVx = ball.linvel().x;
try {
world.step(events);
} catch (err) {
return i; // panic tick
}
let hit = false;
events.drainCollisionEvents((h1, h2, started) => {
if (!started) return;
const pair = [h1, h2];
if (pair.includes(ballCollider.handle) && pair.some((h) => archHandles.has(h))) hit = true;
});
if (hit && !fragmented) {
fragmented = true;
// Replace the composite with three dynamic fragments at identical
// world transforms; lintel gets 60 % of the ball's horizontal momentum.
const t = arch.translation();
const rot = arch.rotation();
world.removeRigidBody(arch);
const ballMomentumX = ball.mass() * prevVx;
PARTS.forEach((pts, k) => {
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic()
.setTranslation(t.x, t.y)
.setRotation(rot)
.setLinvel(RELEASE[k].vx, 0)
.setAngvel(RELEASE[k].w),
);
world.createCollider(hullDesc(pts, radius), body);
if (k === 2) {
body.setLinvel({ x: (0.6 * ballMomentumX) / body.mass(), y: 0 }, true);
}
});
}
}
return null;
}
async function main() {
await RAPIER.init();
const mode = process.argv[2] ?? 'round002';
const radius = mode === 'round002' ? 0.02 : mode === 'round008' ? 0.08 : 0;
let runs = 0, panics = 0;
const ticks = [];
for (const dScale of [0.8, 0.9, 1.0, 1.1, 1.2]) {
for (const hitX of [5.7, 5.9, 6.0, 6.1, 6.3]) {
runs += 1;
const panicTick = run(8.0 * dScale, hitX, radius);
if (panicTick !== null) { panics += 1; ticks.push(panicTick); }
}
}
console.log(`${mode}: ${panics}/${runs} panicked${ticks.length ? ` (ticks: ${ticks.join(', ')})` : ''}`);
}
main();
```
The scene: a fixed ground cuboid; a fixed composite body with three `convexHull` colliders (two slanted parallelogram "feet" and a rectangular "lintel" forming an arch); a ball (r 0.25, restitution 0) dropped onto the lintel with velocity (2, −12). On the first ball↔arch contact event the composite is replaced by three dynamic bodies with the same shapes at identical world transforms and small deterministic release velocities. The ball and the three polygon bodies then settle as a pile on the ground — and `world.step()` hits `unreachable` some 25–80 ticks in.
Stack (wasm offsets, 0.20.0 compat build):
```
RuntimeError: unreachable
wasm:/wasm/005ab5b2:1:1260375
wasm:/wasm/005ab5b2:1:1403970
wasm:/wasm/005ab5b2:1:1404031
wasm:/wasm/005ab5b2:1:491284
wasm:/wasm/005ab5b2:1:221623
wasm:/wasm/005ab5b2:1:679662
N.stepWithEvents (rapier.mjs)
```
## Additional observations (from a larger game harness, same scene)
- **The ball is required.** Removing the ball right after the composite is fragmented: 0/25 crashes in a configuration that otherwise crashes 22/25.
- **Polygon shapes are required.** With the three polygons replaced by cuboid approximations of the same silhouette we have never seen a panic (0/25 in every configuration, and none across the rest of the project's physics suites, which are cuboid/ball-only).
- **Not stiffness-related.** Reproduces at the default `contact_natural_frequency` (the table above) and at 120 (there sharp hulls crash less often, 1/25, while round ones still crash 22/25).
- **The trigger state is not captured by snapshots.** `World.restoreSnapshot(world.takeSnapshot())` taken on the step before the panic does **not** panic when stepped; a fresh world built with the exact panic-frame poses/velocities does not panic either. The trigger seems to live in non-serialized state (contact workspace / warm-start?).
- Identical panic ticks on the 0.20.0 stable wasm and the 2026-08-08 canary wasm.
## Workaround
Model colliders as cuboids/balls only (avoiding `ConvexPolygon`/`RoundConvexPolygon`) — no crashes in any configuration.
Contributor guide
Research direction
Start by running the provided repro.cjs with @dimforge/rapier2d-compat in sharp, round002, and round008 modes, then inspect the World.step() path implicated by the wasm stack. Compare the ConvexPolygon cases with the cuboid control and the fragmentation sequence; done means the deterministic variants complete without RuntimeError while polygon colliders remain supported.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, rust, wasm
- Domain
- game-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100