A game developer's monitor showing a tumbling stack of physics crates with collision wireframes, while a gray-blue cat watches from a nearby shelf

Crashcat: A 3D Physics Engine That Forgot to Use WebAssembly

The Gray Cat
The Gray Cat
0 views

If you have reached for a 3D physics engine in the browser recently, the answer was probably WebAssembly. Rapier is Rust compiled to WASM, Jolt's web build is C++ through Emscripten, and ammo.js is Bullet given the same treatment. That approach buys you raw speed, but it also drags along megabytes of binary, a startup cost while the module instantiates, and an awkward boundary between your JavaScript and the simulation living on the other side of it. Crashcat (npm: crashcat) makes the opposite bet. It is a 3D physics engine written entirely in TypeScript, no WASM anywhere, built for games, simulations, and creative websites by Isaac Mason, a name many React Three Fiber developers will recognise from the physics tooling he maintains in that ecosystem.

The pitch is not that pure JavaScript is faster, because for very large simulations it is not. The pitch is that for a huge range of real projects you do not need a supercomputer-grade solver, and once you drop the WASM requirement you get a pile of conveniences back: tiny tree-shakeable bundles, physics objects you can actually inspect in your debugger, collision callbacks that do not pay a boundary-crossing tax, and a whole world you can serialise to JSON with one function call. Crashcat is early, genuinely early, sitting at version 0.0.4 with a low download count and an API that is still settling. But the design lineage is serious, borrowing ideas from Jolt, Box2D, and Bullet, and it is one of the more interesting libraries to land in the web-physics space in a while.

Why Skip the WebAssembly

It helps to be honest about where this engine wins and where it does not. Crashcat is a good fit when bundle size matters, because there is no binary blob and the library is aggressively tree-shakeable, so you only ship the shapes and constraints you actually register. It shines when your game logic leans hard on physics events, since contact callbacks stay inside the JavaScript VM and never pay the cost of crossing into WASM and back, a tax that can quietly wreck the frame budget in callback-heavy code on other engines. And it is pleasant to work with precisely because everything is a plain object: you can log a rigid body, step through the solver, or JSON.stringify an entire world to save and reload game state.

It is equally honest about the ceiling. The maintainer is upfront that crashcat cannot match an optimised multithreaded WASM engine on enormous simulations. The sweet spot he describes is comfortably simulating hundreds of dynamic bodies at 60 Hz on a typical desktop browser, which is more than enough for most interactive experiences but not the place to build a destruction-heavy AAA title. For that, WASM, or a fully native engine, remains the right tool.

The feature list is broad for such a young project. You get rigid body simulation with static, dynamic, and kinematic bodies; convex shapes (sphere, box, capsule, cylinder, convex hull) alongside triangle meshes, compound shapes, and even experimental custom shapes for things like voxel worlds; eight constraint types with motors and springs; continuous collision detection for fast movers; sleeping, sensors, and flexible collision filtering; a full set of spatial queries; and a built-in kinematic character controller. It is rendering-agnostic, so it works with three.js, Babylon.js, PlayCanvas, or your own engine, and it ships a three.js debug renderer through the crashcat/three export.

Getting It Into Your Project

Installation is exactly what you would expect, with a single dependency on the author's companion math library, mathcat.

npm install crashcat
yarn add crashcat

There is no async initialisation step, no WASM file to host, no await init() before you can create a world. You import and you go, which is one of the quieter benefits of staying pure JavaScript.

Building Your First World

The first thing that surprises people coming from cannon-es or Rapier is the shape of the API. Crashcat is functional and data-oriented rather than object-oriented. There is no new World() followed by body.applyForce(). Instead you have namespaces of functions, and you pass the world object into nearly every call. It feels closer to a database than to a classic OO engine, and once it clicks it is quite ergonomic.

A world is built from settings. Before anything collides you register the shapes and constraints you intend to use, define your collision layers, and then create the world from those settings.

import {
  registerAll,
  createWorldSettings,
  createWorld,
  addBroadphaseLayer,
  addObjectLayer,
  enableCollision,
} from 'crashcat';

// register every built-in shape and constraint up front.
// during development this is the simplest option; you can switch to
// selective registration later to shrink your bundle.
registerAll();

const worldSettings = createWorldSettings();
worldSettings.gravity = [0, -9.81, 0]; // earth gravity, +y is up

// broadphase layers partition space for performance. a "moving" and a
// "not moving" layer is a sensible starting point.
const BROADPHASE_MOVING = addBroadphaseLayer(worldSettings);
const BROADPHASE_STATIC = addBroadphaseLayer(worldSettings);

// object layers control what is allowed to collide with what.
const LAYER_MOVING = addObjectLayer(worldSettings, BROADPHASE_MOVING);
const LAYER_STATIC = addObjectLayer(worldSettings, BROADPHASE_STATIC);

// declare the collision rules between layers.
enableCollision(worldSettings, LAYER_MOVING, LAYER_STATIC);
enableCollision(worldSettings, LAYER_MOVING, LAYER_MOVING);

const world = createWorld(worldSettings);

The two-tier layer system is worth pausing on. Broadphase layers each get their own spatial acceleration structure, a dynamic bounding-volume tree, so separating static terrain from moving debris keeps your queries fast. Object layers sit on top and decide who collides with whom, which is the primary way you say "projectiles hit enemies but not other projectiles." It is a little more setup than a single flat list, but it pays off the moment your scene gets busy.

Populating the Scene With Bodies

With a world in hand, you add rigid bodies. Each body has a shape, a motion type, and an object layer, and from there you can tune dozens of properties.

import { rigidBody, box, sphere, MotionType } from 'crashcat';

// a static ground plane: infinite mass, never moves
rigidBody.create(world, {
  shape: box.create({ halfExtents: [10, 1, 10] }),
  motionType: MotionType.STATIC,
  objectLayer: LAYER_STATIC,
  position: [0, -1, 0],
});

// a stack of dynamic boxes that will tumble under gravity
for (let i = 0; i < 5; i++) {
  rigidBody.create(world, {
    shape: box.create({ halfExtents: [1, 1, 1] }),
    motionType: MotionType.DYNAMIC,
    objectLayer: LAYER_MOVING,
    position: [0, 2 + i * 2, 0],
  });
}

// a bouncy sphere with custom material properties
const ball = rigidBody.create(world, {
  shape: sphere.create({ radius: 0.5 }),
  motionType: MotionType.DYNAMIC,
  objectLayer: LAYER_MOVING,
  position: [3, 8, 0],
  friction: 0.1,
  restitution: 0.9, // close to a perfect bounce
});

There is one gotcha that will bite you if you ignore it, and the docs flag it loudly. Bodies are pooled internally for performance, so you should not hold onto a body object as a long-lived reference. Store body.id instead and look the body up again when you need it. An id carries an index plus a sequence number, and when a body is removed its id is invalidated and the slot can be reused, so a stale reference is a bug waiting to happen.

// don't do this: store the object on your entity
// entity.body = ball;

// do this: store the id, fetch the body when you need it
const ballId = ball.id;

const found = rigidBody.get(world, ballId);
if (found) {
  rigidBody.addImpulse(world, found, [0, 5, 0]); // give it a kick upward
}

Driving the Simulation Forward

A physics world does nothing until you step it. You advance time by calling updateWorld with an optional listener and a delta time, typically inside your render loop.

// the simplest possible loop, fixed at 60 Hz
for (let i = 0; i < 600; i++) {
  updateWorld(world, undefined, 1 / 60);
}

In a real game you would call this from requestAnimationFrame. The documentation strongly nudges you toward a fixed timestep with an accumulator rather than feeding raw frame deltas straight in, because a fixed step keeps the simulation stable and reproducible regardless of how fast rendering happens to run.

const PHYSICS_DT = 1 / 60;
let accumulator = 0;
let last = performance.now();

function frame() {
  const now = performance.now();
  // clamp to avoid a "spiral of death" after a long pause
  accumulator += Math.min((now - last) / 1000, 0.25);
  last = now;

  while (accumulator >= PHYSICS_DT) {
    updateWorld(world, undefined, PHYSICS_DT);
    accumulator -= PHYSICS_DT;
  }

  // render here, interpolating body positions with
  // alpha = accumulator / PHYSICS_DT for smooth visuals
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

A quick word on units, because it is the source of the most common "why is everything moving in slow motion" confusion. Crashcat uses SI units with a +y-up, right-handed coordinate system. A box created with halfExtents: [100, 100, 100] is a hundred-metre cube, skyscraper sized, and it will appear to fall lazily relative to its bulk. Model at the scale you actually mean.

Reacting to Collisions

Because the whole engine lives in JavaScript, listening for collisions is cheap and direct. You pass a listener object into updateWorld and it receives callbacks as contacts come and go.

import type { Listener, RigidBody } from 'crashcat';

const toRemove: RigidBody[] = [];

const listener: Listener = {
  onContactAdded: (bodyA, bodyB) => {
    // example: anything that touches "lava" is doomed
    if (bodyA.userData === 'lava') toRemove.push(bodyB);
    if (bodyB.userData === 'lava') toRemove.push(bodyA);
  },
  onContactPersisted: () => {},
  onContactRemoved: (idA, idB) => {
    // careful: the bodies may already be gone, only ids are safe here
  },
};

updateWorld(world, listener, 1 / 60);

// it is not safe to remove bodies inside the callbacks; do it after the
// step completes, when the solver is no longer mid-flight.
for (const body of toRemove) {
  rigidBody.remove(world, body);
}

That deferred-removal pattern is the important detail. Removing a body inside a contact callback corrupts the solver's internal state, so you collect ids during the step and act on them once updateWorld returns. The userData field, shown here tagging a body as lava, is a handy slot for stashing your own entity ids so you can bridge from a physics contact back to your game objects.

Asking the World Questions

Beyond simulating motion, you frequently need to ask the world things without stepping it: what is under the mouse cursor, is there line of sight, what is inside this explosion radius. Crashcat covers these with a family of query functions, each available in closest, any, and all flavours so you can pick the cheapest one for the job. Here is a raycast, the workhorse used for picking and line-of-sight.

import {
  castRay,
  createClosestCastRayCollector,
  createDefaultCastRaySettings,
  filter,
  CastRayStatus,
  vec3,
} from 'crashcat';

const origin = vec3.fromValues(0, 5, 0);
const direction = vec3.fromValues(0, -1, 0); // straight down
const length = 100;

// a filter controls which layers and bodies the ray is allowed to hit
const queryFilter = filter.create(world.settings.layers);

const collector = createClosestCastRayCollector();
const settings = createDefaultCastRaySettings();

castRay(world, collector, settings, origin, direction, length, queryFilter);

if (collector.hit.status === CastRayStatus.COLLIDING) {
  const distance = collector.hit.fraction * length;
  const hitBody = rigidBody.get(world, collector.hit.bodyIdB);
  console.log('hit a body at distance', distance, hitBody?.id);
}

The same collector pattern extends to castShape for sweeping a shape through the world, collidePoint and collideShape for overlap tests, and lower-level broadphase queries when you want to traverse the bounding-volume tree yourself. The filter object is the consistent thread through all of them, letting you restrict hits by object layer, broadphase layer, 32-bit collision group and mask, or an arbitrary callback for the trickiest cases.

Characters, Constraints, and Shrinking the Bundle

Two more capabilities round out a real game. Crashcat ships a built-in kinematic character controller under the kcc namespace, with the behaviours players expect: sliding along walls, stepping up stairs, handling slopes, sticking to the ground on the way down, and riding moving platforms. It also offers a copy-paste floating-capsule controller recipe for when you want a character that behaves more like a physics object. For mechanical assemblies there are eight constraint types, from a simple pointConstraint ball-and-socket through hingeConstraint, sliderConstraint, and the fully configurable sixDOFConstraint, all supporting motors and springs so you can build doors, vehicles, and ragdolls.

When you are ready to ship, swap the convenient registerAll() for selective registration and let your bundler tree-shake away everything you do not touch.

import { registerShapes, registerConstraints } from 'crashcat';
import { sphere, box, capsule } from 'crashcat';
import { hingeConstraint, distanceConstraint } from 'crashcat';

// only these shapes and constraints end up in the final bundle
registerShapes([sphere.def, box.def, capsule.def]);
registerConstraints([hingeConstraint.def, distanceConstraint.def]);

This is the payoff of the pure-JavaScript design. Because there is no monolithic WASM module, the bundler can genuinely eliminate the cylinder, convex hull, triangle mesh, and constraint code you never call, and you pay only for what you use.

Should You Reach for It Yet

Crashcat is at version 0.0.4, and that number deserves respect. Weekly downloads are still small, the API has renamed methods and reshaped signatures across its short release history, and the maintainer is candid that determinism, while considered in the design, has not been deeply tested. This is not yet the safe, boring default you drop into a large production game without a second thought.

What it is, instead, is one of the most promising things to appear in browser physics in a while, and a genuinely sensible choice for prototypes, game jams, interactive marketing sites, and creative web experiences where bundle size, debuggability, and rich physics callbacks matter more than squeezing out the last drop of throughput. Its credibility rests on two things: an author with a long track record across the React Three physics ecosystem, and a design borrowing from Jolt, Box2D, and Bullet rather than inventing from scratch. If you live in the world of three.js and creative coding, this is one to prototype with now and watch closely as it heads toward 1.0.