Skip to content

Repository files navigation

CutestC2

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.

Install

npm install cutestc2

The package is ESM-only. Node.js 18+ and modern browsers are supported by the main build.

Collision

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.

Allocation-sensitive queries

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.

Navigation

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.

Crowd steering

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.

Baked navmeshes

Installations include the offline baker:

npx --no-install cutestc2-bake-navmesh --map ./level.mjs --out ./level.navmesh

The 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.

Legacy global build

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.

Public API

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.

Runtime deployment

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

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:pack

After npm run build, run npm run demo and open http://localhost:8000/demos/game/ to preview the PixiJS playtest.

Benchmarks

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.

About

Fast 2D collision, navigation, and crowd AI powered by WebAssembly.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages