Custom solver adapters

The stable @fourjs/physics API never talks to a physics engine directly: it talks to an adapter (§37). physics-rapier ships two of them (Rapier2dAdapter, Rapier3dAdapter); physics-box2d is the scaffolded second solver. This guide describes the contract a new adapter signs, the seams the shipped ones actually implement, and the honest lessons from adapting Rapier.

The contract, in layers

What PhysicsWorld requires is a type intersection (defined in four/physics):

type PhysicsWorldAdapter = PhysicsSolverAdapter & SolverBodyAccess;

PhysicsSolverAdapter (§37) is the world-shaped half: identity (name, version), a capabilities record, async-capable initialize(options) — where a wasm solver decodes its image — createBody / destroyBody, createCollider / destroyCollider, createJoint / destroyJoint, step(delta), drainEvents(), syncSceneToSolver() / syncSolverToScene(), the four §30 queries (raycast, shapeCast, overlap, pointQuery), optional takeSnapshot?() / restoreSnapshot?(buffer) (§34), and dispose().

SolverBodyAccess is an engine seam _beyond_ §37's sketch (decision, Phase 5): per-handle transform/velocity/force/kinematic accessors, mirrored member-for-member by both Rapier adapters. §37 alone cannot move a solved pose onto a node — it has no per-body read — so both halves are required.

SolverJointAccess joins them for any adapter with joints (§28): live setJointLimits / setJointMotor command application, detected structurally (supportsSolverJointAccess) rather than declared.

Capabilities: declare what you are (§37)

PhysicsCapabilities is checked at world construction, not trusted later:

readonly capabilities: PhysicsCapabilities = {
  dimensions: ["2d"],              // a "3d" world refuses this adapter
  jointTypes: ["fixed", "revolute", "prismatic", "rope", "spring"],
  ccdModes: ["disabled", "speculative"],
  determinism: "same-runtime",     // may not be weaker than the world asks
  snapshots: true,                 // both snapshot methods implemented
  queries: { raycast: true, shapeCast: true, overlap: true, point: true },
};

Declare honestly. The engine's posture on missing features is _loud refusal with the measured reason_, not emulation — Rapier 0.20.0 exposes no joint reaction forces, so both adapters declare reportsJointReactions = false on their joint-access seam and breakable joints are refused; per-axis limits cannot form a cone, so a limited spherical joint is refused quoting the numbers.

Using an adapter (the consumer side, complete)

import { PhysicsWorld, RigidBody, Collider } from "fourJS/physics";
import { Rapier2dAdapter } from "fourJS/physics-rapier";
import { Group } from "fourJS/scene";

const adapter = new Rapier2dAdapter();
const world = new PhysicsWorld({ dimension: "2d", adapter });
await world.initialize(); // adapter.initialize(): the wasm image decodes here

const ball = new Group();
ball.transform.position.set(0, 3, 0);
ball.transformAuthority = "physics";
ball.addComponent(new RigidBody({ type: "dynamic" }));
ball.addComponent(new Collider({ shape: { type: "circle", radius: 0.5 } }));
world.addBody(ball);

for (let i = 0; i < 120; i += 1) world.step(1 / 60);
console.log(ball.transform.position.y, world.checksum()); // solved pose + §33 digest

Swapping Rapier2dAdapter for another conforming adapter is the only line that changes — that is §20's promise, and examples/physics-playground demonstrates it across dimensions with a six-field kit.

Rules a new adapter must keep

Honest state

Cross-references