Fast, typed 2D collision, spatial queries, navigation and crowd steering built
on cute_c2 and WebAssembly.
CutestC2 provides isolated WASM state per world, synchronous and asynchronous factories, reusable output containers for hot loops, ESM subpath exports and a classic-script bundle for NW.js 0.29 / Chromium 65.
npm install cutestc2The package is ESM-only. Node.js 18+ and modern browsers are supported by the main build.
import { CollisionWorld, CircleShape, AABBShape } from 'cutestc2';
const world = await CollisionWorld.create();
const wall = world.createCollider({ x: 160, y: 80, layer: 2, static: true });
wall.addShape(new AABBShape({ width: 20, height: 160 }));
const actor = world.createCollider({ x: 20, y: 80, layer: 1, mask: 2 });
actor.addShape(new CircleShape(12));
const result = actor.move(200, 0, { slide: true });
console.log(result.hit, actor.x, actor.y);
world.dispose();For startup paths that already own bytes or a compiled module:
const worldFromBytes = CollisionWorld.createSync({ wasm: bytes });
const worldFromModule = CollisionWorld.createSync({ wasm: { module: compiledModule } });Every CollisionWorld owns a separate WebAssembly.Instance. Disposing one
world cannot invalidate another. A world owns its colliders and shapes until
they are explicitly disposed or the world is disposed. Disposal is idempotent;
using a disposed handle throws.
layer identifies a collider's category and mask selects the categories it
can interact with. Movement uses the moving collider's mask; query methods use
the query's mask. A sensor participates in queries but never blocks movement.
Call world.updateSensors() once per simulation step, or use
world.onSensor(), to receive persistent and complete pass-through transitions.
Editable polyline and complex-polygon points are committed with
shapeInstance.update(). A rejected edit reports valid/error and preserves
the last committed native geometry.
Hot-path APIs accept reusable output containers:
const hits = [];
world.queryAABB(0, 0, 100, 100, { out: hits });
const rayHits = [];
world.raycastMany(rays, { mask: 2, out: rayHits });
const previousMoveResult = actor.move(0, 0);
const moveResult = actor.move(4, 0, { out: previousMoveResult });
const pairs = world.createPairBatch(128);
pairs.push(actor, wall);
const manifolds = world.collidePairs(pairs);Public geometry rejects non-finite coordinates; bounded arguments reject infinities and invalid ranges. Infinite ray distance is supported only in non-looping worlds.
NavigationWorld rasterizes static collision into streamed grid chunks and
runs A* in workers:
import { NavigationWorld } from 'cutestc2/navigation';
const navigation = await NavigationWorld.create(world, {
obstacleMask: 2,
chunkSize: 256,
cellSize: 8,
maxChunks: 128,
workers: 'auto',
backend: 'auto',
profiles: [{ id: 'actor', radius: 12, margin: 2 }],
});
navigation.activateChunk(0, 0);
await navigation.flush();
const [path] = await navigation.findPaths([{
start: { x: 32, y: 32 },
goal: { x: 220, y: 220 },
profile: 'actor',
}]);
if (path.status === 'ok' || path.status === 'partial') {
console.log(path.points); // [x0, y0, x1, y1, ...]
}cellSize controls grid detail; halving it quadruples the cells per chunk, so
size maxChunks against the two-million-cell safety budget. Streamed games can
replace their resident set atomically with setActiveChunks() and inspect
activeChunkCount/maxChunks without duplicating capacity state.
Goal-sharing requests switch to a bounded flow field at flowFieldThreshold
agents (default 8); smaller groups use individual A*. Set the option to
false to disable flow fields or tune the threshold for a game's crowd size.
For moving targets, agent.retarget(x, y, options) keeps the installed route as
a best-effort steering bridge until the replacement path is ready. Collision
remains authoritative if streamed topology changes underneath it. Use
allowPartial: true when an agent should advance to the closest reachable point
instead of stopping on an unreachable goal.
Debug renderers can inspect agent.currentWaypointIndex to draw only the
remaining portion of the installed agent.path without depending on private
crowd state. Grid overlays can cache getDebugChunk() results by their returned
version and compare it with getChunkVersion(cx, cy), so a streamed topology
change only invalidates the chunks whose committed navigation data changed.
Path statuses are ok, partial, unreachable and outside. partial is
returned only when allowPartial is enabled and ends at the closest reachable
point. goalTolerance treats the destination as an area, and snapDistance
controls how far an off-grid endpoint may be projected. Use an AbortSignal
and query priority for cancellable, ordered asynchronous work.
Workers are recovered and their committed chunks replayed after an unexpected
failure. flush() commits dirty chunks before dependent queries; failed commits
remain dirty so a later call can retry them.
Both navigation backends expose the same crowd API:
const crowd = navigation.createCrowd({ neighborDistance: 96, lookAhead: 48 });
const agent = crowd.add(actor, {
profile: 'actor',
radius: 12,
maxSpeed: 180,
maxAcceleration: 900,
});
agent.setGoal(220, 220, { allowPartial: true });
await crowd.flushRoutes(); // optional deterministic warm-up
for (const event of crowd.update(deltaSeconds)) {
if (event.type === 'partial') console.log('closest reachable point reached');
}Each collider can belong to at most one agent in a crowd. retarget() keeps a
safe installed route while the replacement is planned, avoiding a movement
stall. setGoal() replaces pending work, while clearGoal() prevents late
route results from reactivating an idle agent. A partial route finishes in the
partial state, not arrived. Dispose agents when entities despawn, then
dispose the crowd before its navigation backend.
Installations include the offline baker:
npx --no-install cutestc2-bake-navmesh --map ./level.mjs --out ./level.navmeshThe map module exports a synchronous buildMap(world) function that adds static
obstacle colliders, plus its bake configuration:
import { AABBShape } from 'cutestc2';
export function buildMap(world) {
const wall = world.createCollider({ static: true, layer: 2, x: 160, y: 80 });
wall.addShape(new AABBShape({ width: 20, height: 160 }));
}
export const navmeshConfig = {
bounds: { minX: 0, minY: 0, maxX: 320, maxY: 320 },
obstacleMask: 2,
profiles: [{ id: 'actor', radius: 12, margin: 2 }],
};Load the generated file with the polygon backend:
import { NavmeshWorld } from 'cutestc2/navmesh';
const navmesh = await NavmeshWorld.create(world, { url: './level.navmesh' });
const [path] = await navmesh.findPaths([{ start, goal, profile: 'actor' }]);The baker can also write a readable diagnostic file with --json. Its binary
output is validated again when loaded by NavmeshWorld.
Load dist/legacy/cutestc2.legacy.js as a classic script to expose
globalThis.CutestC2. The matching classic worker is
dist/legacy/navigation.worker.js.
<script src="cutestc2.legacy.js"></script>
<script>
CutestC2.CollisionWorld.create({ wasm: './wasm/collision.wasm' })
.then(function (world) { /* use world */ });
</script>The target is tested in NW.js 0.29.4 / Chromium 65. That Chromium version
rejects synchronous compilation and instantiation of WASM modules larger than
4 KiB on the main thread, so use CollisionWorld.create() and
NavmeshWorld.loadMeshAsync() there. createSync() and loadMesh() remain
available in runtimes that permit synchronous WASM.
The package root exports collision, navigation, navmesh and crowd APIs. Focused
imports are available at cutestc2/navigation, cutestc2/navmesh and
cutestc2/crowd. Type declarations are generated from the implementation into
dist/types; the exported declarations are the supported contract.
Keep the packaged dist/wasm files next to the ESM build, and let your bundler
preserve import.meta.url asset resolution. Grid navigation also loads
navigation.worker.js. The shared backend requires cross-origin isolation;
backend: 'auto' uses it when available and otherwise selects isolated workers.
For a custom CDN or asset pipeline, pass explicit WASM sources or URLs through
the factory options.
Development requires Node.js 20+ and Zig 0.16:
npm ci
npm run build
npm run typecheck
npm test
npm run test:legacy
npm run test:e2e
npm run test:packAfter npm run build, run npm run demo and open
http://localhost:8000/demos/game/ to preview the PixiJS playtest.
npm run bench reports median and p95 query/movement throughput.
npm run bench:check compares a clean build with the conservative versioned
baseline in bench/baselines/v1.json, and npm run bench:memory checks retained
heap after repeated world creation/disposal. Additional grid, sweep and
navigation benchmarks are available through the bench:* scripts.