Installation
Install the public @colossum/physics package from npm:
npm install @colossum/physics
Copy the verified Emscripten modules, WebAssembly binaries, worker code, and manifest into your application’s public directory:
npx colossum-physics-assets public/colossum-physics
The command creates /colossum-physics/manifest.json, the default runtime location used by the npm
build. Commit the copied directory or run the command as part of your application’s build. For a
framework with a different static directory, pass that directory followed by colossum-physics as
the destination.
import { createPhysicsRuntime, PhysicsRuntimeError } from "@colossum/physics";
const runtime = await createPhysicsRuntime({ variant: "auto" });
Hosting and browser requirements
Serve the application page and /colossum-physics/ from the same origin. Pthread variants use Web
Workers and shared WebAssembly memory, so the application response must include:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
The shipped runtimes require WebAssembly SIMD. Basic simulation does not require WebGPU, but
syncPosesForFrame() and the renderer contract do. Pthread selection additionally requires
crossOriginIsolated === true, SharedArrayBuffer, and enough reported logical processors for the
selected worker count. Without those pthread prerequisites, variant: "auto" falls back to the
single-thread SIMD build. A browser without WebAssembly SIMD receives NO_COMPATIBLE_VARIANT.
Keep the manifest, module JavaScript, worker entry, and WASM on the application origin. Models, textures, and other non-runtime assets may use any CDN or asset host—including infrastructure provided through Colossum Wavefront—when its CORS and CORP headers permit the game, page, or simulation origin.
Runtime creation
const runtime = await createPhysicsRuntime({
variant: "auto",
allowFallback: true,
maxWorlds: 16,
device, // optional default device for pose uploads
});
The npm build defaults to /colossum-physics/manifest.json. Use manifestUrl only when the copied
runtime directory has another public URL. A manifest selects executable module JavaScript, so load
it only from an origin you trust. Schema, build-identity, and ABI validation reject malformed or
incompatible metadata; they do not make an arbitrary manifest or module URL trustworthy.
The shipped manifest contains these variants:
| Variant | Automatic policy | Requirements |
|---|---|---|
simd-pthread-4 |
First automatic preference | SIMD, isolation, SharedArrayBuffer, and at least 4 logical processors |
simd-single |
Automatic portability fallback | SIMD |
simd-pthread-8 |
Explicit scaling option | SIMD, isolation, SharedArrayBuffer, and at least 8 logical processors |
Request an exact id with variant, or put ids first with selectionOrder. allowFallback defaults
to true; when enabled, an unavailable or failed preferred module leaves a diagnostic reason and
the loader tries the next compatible candidate. Set it to false when selecting an exact artifact
is more important than recovery. simd-pthread-8 follows the normal production candidates during
fallback, but request or prioritize it explicitly when eight-worker scaling is the intended policy.
manifest, fetch, capabilities, moduleFactories, and moduleLoader are advanced integration
and testing seams. Emscripten module objects and underscored exports remain sealed behind the typed
adapter.
maxWorlds defaults to 16 and is capped at Box3D’s pinned 128-world limit. Threaded runtimes still
apply the stricter one-live-world pool rule described below.
runtime.diagnostics() returns the requested/selected backend, worker counts, feature state,
fallback reasons, heap and build identities, and active-world count.
World lifecycle
const world = runtime.createWorld({
capacity: 2_001,
visualCapacity: 2_001,
staticBodyCapacity: 32,
contactCapacity: 16_008,
convexHullCapacity: 8,
gravity: [0, -9.81, 0],
enableSleep: false,
});
World and renderer capacities are fixed. Creation validates the entire descriptor and either returns a live world or fails without leaking a native world.
| Option | Default and purpose |
|---|---|
capacity |
Required body-slot capacity; maximum 65,534 |
visualCapacity |
capacity; maximum 65,535 renderer slots |
staticBodyCapacity |
min(capacity, max(1, ceil(capacity / 64))); expected static-body allocation |
contactCapacity |
min(131072, max(256, capacity * 8)); fixed contact allocation |
convexHullCapacity |
0; fixed reusable hull-asset slots, maximum 4,096 |
gravity |
[0, -9.81, 0] |
enableSleep / enableContinuous |
true; world-wide sleeping and continuous collision behavior |
Advanced Box3D tuning fields retain stable adapter defaults: restitutionThreshold: 1,
hitEventThreshold: 1, contactHertz: 30, contactDampingRatio: 10,
maximumContactPushSpeed: 3, and maximumLinearSpeed: 400. Capacity settings reserve native
resources; the runtime never silently resizes a live world.
Worker count is fixed by the selected artifact, not mutable per world. Pthread artifacts use one
strict preallocated Box3D worker pool and therefore allow one live world at a time. Destroy that
world before creating its replacement. Single-thread runtimes may use the configured maxWorlds
slots concurrently.
The runtime owns every world it creates. world.destroy() may be called explicitly and is
idempotent; runtime.destroy() destroys any remaining worlds, shuts down the module, and is also
idempotent. Pthread module shutdown terminates its preallocated worker pool; construction or
initialization failures do the same before fallback. Shutdown is terminal, and using a destroyed
runtime/world raises a typed error.
Batch body and shape creation
const handles = world.createBodies({
bodies: [
{
motionType: "dynamic",
position: [0, 4, 0],
orientation: [0, 0, 0, 1],
shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] },
density: 1,
friction: 0.6,
},
{
motionType: "dynamic",
position: [1.2, 5, 0],
shape: { kind: "sphere", radius: 0.45 },
},
],
visualSlots: [10, 11],
});
The batch is packed into one wasm descriptor span and sent through one native creation call. Sphere, box, capsule, finite-cylinder, and reusable convex-hull shapes are supported. Capsules and cylinders are aligned to the local Y axis. Cylinder sides accept 3 through 32 and default to 16.
position and shape are required. Bodies otherwise default to dynamic motion, identity
orientation, zero linear/angular velocity, density 1, friction 0.6, restitution 0, damping 0,
gravity scale 1, and an awake, enabled, sleep-capable state. Input quaternions are normalized.
bullet, fixedRotation, and enableFastRotation opt into Box3D’s fast-motion or rotation policies;
linearDamping, angularDamping, rollingResistance, and sleepThreshold tune behavior without
changing the shape.
Visual slots are stable, unique indirections. Set BodyDescriptor.visualSlot or the batch-level
visualSlots array when render-instance order must be explicit. Otherwise the world prefers the
body’s native pose slot and then the next free visual slot.
Capacity and explicit-slot validation happen before native mutation. Native creation is also validation-first. A failed batch never exposes a partial result.
Reusable convex hulls
Reserve hull slots with convexHullCapacity, cook a point cloud once, and reuse its opaque handle
across body instances:
const hull = world.createConvexHull({
points: new Float32Array([
0, 1, 0,
-1, -1, -1,
1, -1, -1,
0, -1, 1,
]),
maxVertices: 44,
});
const [body] = world.createBodies([{
position: [0, 4, 0],
shape: { kind: "convexHull", hull, scale: [1, 2, 1] },
}]);
points is a flat xyz array containing 4 through 4,096 finite points. Box3D builds a convex hull and
retains at most maxVertices, which defaults to and cannot exceed 44. Instance scale defaults to
[1, 1, 1] and must be positive on every axis.
Hull handles belong to the world that created them and are generation-safe. Use
destroyConvexHull() or destroyConvexHulls() when the template is no longer needed for new body
creation; already-created bodies retain their instantiated shapes. World destruction releases any
remaining hull assets.
Height fields
const terrain = world.createHeightField({
columns,
rows,
heights, // row-major f32-compatible samples
cellSize: [1, 1], // X/Z spacing
heightScale: 1,
origin: [-32, 0, -32],
friction: 0.8,
});
Height-field creation performs one construction-time sample upload into the wasm heap. Box3D owns its compressed/cooked terrain afterward; no height data crosses the JS/WASM boundary during a frame. Height fields are capped at 4,194,304 samples before allocation so dimensions cannot wrap or consume the fixed heap pathologically. The returned handle obeys the same generation and visual-slot rules as other bodies.
columns and rows must each be at least 2, and heights must contain exactly columns * rows
finite samples in row-major order. Sample (column, row) is positioned at
origin + [column * cellSizeX, heights[row * columns + column] * heightScale, row * cellSizeZ].
origin defaults to [0, 0, 0], heightScale defaults to 1, friction defaults to 0.8, and
restitution defaults to 0. Set clockwise: true only when the consuming coordinate/winding
convention requires reversed height-field triangles.
A height field is one enabled static body. Disable or re-enable it through the normal handle API,
and release it with world.destroyBody(terrain).
Handles, activation, and removal
BodyHandle is an opaque frozen object with readable bodySlot and visualSlot mapping fields.
Its native generation token and world ownership are private. Do not manufacture look-alike objects.
world.setBodyAwake(handle, true);
world.setBodyEnabled(handle, false);
world.setBodyEnabled(handle, true);
world.destroyBody(handle);
setBodyAwake changes the body’s sleep state without changing its identity. setBodyEnabled(false)
removes the body from active simulation and collision participation until it is enabled again;
enablement does not allocate a replacement handle. destroyBody permanently releases the native
body and renderer mapping.
After destruction, the handle is stale forever. A new body may reuse its numeric pose/visual slot, but native and TypeScript generation/ownership validation rejects the old object. Passing a handle from another world is also a stale-handle error. Synchronize poses after activation, destruction, or creation before rendering the resulting state.
Renderer contract: stepping and pose synchronization
The rendering boundary deliberately separates simulation from presentation. world.step() advances
Box3D state, while world.syncPosesForFrame() publishes the latest body transforms to world-owned
WebGPU buffers and returns the RendererContract needed to bind them. This keeps per-body transform
queries out of the JavaScript render loop.
const step = world.step(1 / 60, { substeps: 4 });
const contract = world.syncPosesForFrame(device);
step makes one synchronous Box3D call for the supplied interval. substeps controls Box3D’s
internal subdivision of that interval; it does not add JavaScript-to-WASM calls. The returned
StepResult reports the step serial, effective dt, substep count, and measured step time.
Call syncPosesForFrame after the simulation or lifecycle mutation whose state the renderer should
consume. It packs poses at most once for that native mutation generation, moves the old current pose
buffer into previous history, and performs at most one contiguous upload for the new current poses.
Repeating synchronization without another step, topology change, or activation change performs no
pose work and returns uploadedThisSync: false. Render-only frames can keep using the existing GPU
buffers.
The returned contract exposes the complete GPU binding agreement:
posePreviousandposeCurrentare storage buffers indexed by body slot. Each pose occupies 32 bytes: a positionf32x4followed by a quaternionf32x4.visualToBodystores oneu32body slot for each visual slot;0xffffffffmeans unassigned. Use this indirection when render-instance order differs from native body-slot order.interpolationidentifies the previous and current generations. Supply an alpha from your own fixed-step accumulator and interpolate previous to current; the physics runtime does not choose render timing or calculate that alpha.poseCountis the addressable pose span and can include stable holes below its high-water mark;activeBodyCountis the number of live bodies.resourceEpochidentifies the lifetime of the GPU resources. Buffer identities remain stable within an epoch, so rebuild bind groups when the epoch changes.
The buffers are borrowed, world-owned resources: bind and read them, but do not write, resize, or
destroy them. The world updates visualToBody when its topology mapping changes.
Creation, destruction, and slot reuse re-prime history by uploading current first and copying it to previous. This deliberately collapses interpolation for one generation so a new body never blends from an unrelated body’s old transform.
The first call needs a GPUDevice unless one was provided when creating the runtime. Later calls may
pass that device’s GPUQueue. Passing another device deliberately replaces all world-owned GPU
resources and increments resourceEpoch; a queue from another device is rejected.
rendererContract() returns the most recent synchronized contract without performing work. It
fails before the first successful synchronization.
Device loss
The world observes device.lost, marks its renderer contract unavailable, destroys the world-owned
GPU buffers, and increments the resource epoch. Box3D simulation state remains alive. Synchronizing
with a replacement GPUDevice creates and primes new buffers; the renderer must rebuild its bind
group for the new epoch. The old RendererContract and its buffers must not be reused.
rendererContract() and attempts to resynchronize with the lost device fail with
GPU_DEVICE_LOST. Acquire a replacement device, call syncPosesForFrame(replacementDevice), and
bind the newly returned contract. Simulation may continue while renderer resources are unavailable.
Diagnostics and performance
const runtimeInfo = runtime.diagnostics();
const worldInfo = world.diagnostics();
Runtime diagnostics report requested and selected variants, worker counts, browser capability probes, fallback reasons, fixed heap size, build identity, and active-world/disposal state. World diagnostics add body and hull capacity/usage, pose and submission generations, renderer resource epoch, device-loss state, and decoded native Box3D counters.
worldInfo.timings separates the most recent synchronous step, native pose pack, pthread staging
copy, and GPUQueue.writeBuffer time. stagedBytes is nonzero when a shared WASM heap required a
non-shared upload view; uploadedBytes is the current contiguous pose span. lastQueueDrainMs is
observed asynchronously with onSubmittedWorkDone() and remains null until an observation
completes.
Native counters refresh when a changed world is packed by syncPosesForFrame; reading diagnostics
alone does not force a pack or mutate simulation state. Use these measurements for workload and
variant comparisons, not as fixed performance guarantees across browsers or devices.
Typed failures
try {
world.step(1 / 60);
} catch (error) {
if (error instanceof PhysicsRuntimeError) {
console.error(error.code, error.message, error.details);
}
}
PhysicsRuntimeError.code is one of:
| Code | Meaning or recovery |
|---|---|
INVALID_ARGUMENT |
Correct the caller-supplied option, descriptor, handle batch, or GPU queue |
INVALID_MANIFEST |
Serve a supported, well-formed manifest and pinned build identity |
NO_COMPATIBLE_VARIANT |
Meet SIMD/thread prerequisites or choose an available variant |
MODULE_LOAD_FAILED |
Check asset URLs, response policy, module initialization, and fallback reasons |
ABI_MISMATCH |
Deploy matching package, manifest, module JavaScript, and WASM artifacts |
NATIVE_ERROR |
A native Box3D operation failed; inspect details and the chained cause |
CAPACITY_EXHAUSTED |
Release resources or create a replacement world with larger fixed capacities |
STALE_BODY_HANDLE |
Stop using a destroyed, replaced, manufactured, or foreign body handle |
STALE_CONVEX_HULL_HANDLE |
Stop using a destroyed, replaced, manufactured, or foreign hull handle |
WORLD_DESTROYED / RUNTIME_DESTROYED |
Create a new live owner; shutdown is terminal |
GPU_DEVICE_REQUIRED |
Initialize renderer resources with a GPUDevice, not only a queue |
GPU_DEVICE_LOST |
Acquire a replacement device and synchronize again |
RENDERER_NOT_SYNCHRONIZED |
Call syncPosesForFrame(device) before reading the contract |
HEIGHTFIELD_UNAVAILABLE |
Use a runtime artifact that exports height-field support |
details supplies structured context when available, and cause retains a lower-level failure when
one exists. A queue from a different device is an INVALID_ARGUMENT; it does not silently replace
renderer resources.
Errors do not trigger a hidden resize, module switch after world creation, or partial lifecycle operation.