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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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';constworld=awaitCollisionWorld.create();constwall=world.createCollider({x: 160,y: 80,layer: 2,static: true});wall.addShape(newAABBShape({width: 20,height: 160}));constactor=world.createCollider({x: 20,y: 80,layer: 1,mask: 2});actor.addShape(newCircleShape(12));constresult=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:

constworldFromBytes=CollisionWorld.createSync({wasm: bytes});constworldFromModule=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:

consthits=[];world.queryAABB(0,0,100,100,{out: hits});constrayHits=[];world.raycastMany(rays,{mask: 2,out: rayHits});constpreviousMoveResult=actor.move(0,0);constmoveResult=actor.move(4,0,{out: previousMoveResult});constpairs=world.createPairBatch(128);pairs.push(actor,wall);constmanifolds=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';constnavigation=awaitNavigationWorld.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);awaitnavigation.flush();const[path]=awaitnavigation.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:

constcrowd=navigation.createCrowd({neighborDistance: 96,lookAhead: 48});constagent=crowd.add(actor,{profile: 'actor',radius: 12,maxSpeed: 180,maxAcceleration: 900,});agent.setGoal(220,220,{allowPartial: true});awaitcrowd.flushRoutes();// optional deterministic warm-upfor(consteventofcrowd.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';exportfunctionbuildMap(world){constwall=world.createCollider({static: true,layer: 2,x: 160,y: 80});wall.addShape(newAABBShape({width: 20,height: 160}));}exportconstnavmeshConfig={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';constnavmesh=awaitNavmeshWorld.create(world,{url: './level.navmesh'});const[path]=awaitnavmesh.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.

<scriptsrc="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