Testing physics and determinism
Physics that you cannot test turns into physics you are afraid to touch. Ferret's physics runs without a renderer, without a GPU and without a browser, under a fixed step, so tests are small and fast.
What it is for#
- proving a capability does what its law says (a ball falls √(2h/g) seconds; a rope carries m·g);
- keeping a tuned scene from drifting when code changes (a golden trace);
- checking replication and replays (the same inputs, the same run).
The headless world#
makeWorld() in tests/helpers.ts builds a world on Babylon's NullEngine: a real scene graph, no canvas. The worked examples of these guides, which live in tests/unit/examples, use it. In your own tests:
import { makeWorld } from '../helpers';
const tw = makeWorld(); // { engine, scene, world, dispose }
Havok runs headless as well: await loadHavok() (from tests/havok.ts) reads the WASM from the package and hands it to HavokKernel. One module per test file.
Driving time#
| Call | What it does |
|---|---|
world.step() | exactly one fixed step (60 Hz unless you set fixedStep) |
world.run(seconds) | that much simulated time |
host.fixedUpdate(dt) | step the physics host alone, for a test with no entities |
world.advance(realDt) | what the game loop calls: runs as many fixed steps as fit the frame time |
Do not use real time in a test. A test that waits is a test that flakes.
Assert against a law, not against a number you saw#
The strongest tests compute the expected value from the physics the capability claims:
import { PhysicsHost, LightKernel } from '@ferret/physics';
const host = world.addService(new PhysicsHost({ kernel: new LightKernel({ gravity: [0, -9.81, 0] }) }));
const ball = host.kernel.addBody({ shape: { type: 'sphere', radius: 0.1 }, mass: 1, position: [0, 20, 0], gravityScale: 1 });
const t = Math.sqrt(2 * 20 / 9.81); // time to fall 20 m
for (let i = 0; i < Math.round(t * 60) - 1; i++) host.fixedUpdate(1 / 60);
Choose tolerances from the integrator: a fixed step of 1/60 s with semi-implicit integration is exact to a few per cent of g·dt for free fall. State the tolerance and why, once, in the test.
Determinism: what it promises#
The same inputs run through the same build give the same result, bit for bit, on the same machine: the step is fixed, capabilities run in a fixed order (phase, order, name), the light kernel has no randomness and capabilities that need chance take a seed (seededRng). That is what makes replays, lockstep checks and golden traces possible.
import { PhysicsHost, LightKernel } from '@ferret/physics';
function run() {
const tw2 = makeWorld();
const host = tw2.world.addService(new PhysicsHost({ kernel: new LightKernel({ gravity: [0, -9.81, 0] }) }));
host.kernel.addBody({ shape: { type: 'plane', normal: [0, 1, 0], offset: 0 } });
const bodies = [0, 1, 2].map(i => host.kernel.addBody({ shape: { type: 'sphere', radius: 0.3 }, mass: 1 + i, position: [i * 0.5, 2 + i, 0], velocity: [1, 0, 0.5] }));
for (let i = 0; i < 300; i++) host.fixedUpdate(1 / 60);
const out = bodies.map(b => b.position.join(','));
tw2.dispose();
return out;
}
const a = run(), b = run();
What it does not promise: the same bits on a different CPU architecture or between a Node build and a browser build (floating-point and the Havok WASM can differ in the last digits); the same run if you change a capability's code, a value, a material, or the order you call things in your own code. Network play therefore replicates state (positions, velocities, events), not inputs-only lockstep, unless both sides run the same build; see the net guide.
Golden traces#
For a tuned scene (a bridge that must hold exactly this load), record a trace: positions of a few chosen bodies every N steps, as numbers rounded to a stated precision, stored in the repo. The test replays the scene and compares within a tolerance. When a deliberate change moves the numbers, regenerate the trace in the same commit and say why in the message. A golden trace that is regenerated without a reason is not a test.
Visual checks#
Physics that looks wrong but passes numbers (a piece that rotates the wrong way, a mirrored mesh) needs eyes. The Playwright runs in tests/e2e load a sample and take screenshots; the physics samples under examples/physics are the ones to open in the simulator. Keep one scene per behaviour so a screenshot says one thing.
Debugging#
| Symptom | Cause |
|---|---|
| A test passes alone and fails in the suite | shared state: a world per test, tw.dispose() after; Havok is one module per file |
| Two runs differ | an unseeded random, wall-clock time, object iteration order of something you built, or a body created in a different order |
Havok test: fetch of a file URL fails | load through tests/havok.ts, which reads the binary |
| A tolerance has to be huge | the test is checking something the law does not say: a bounce, a friction regime, a sleeping threshold; assert what the law does say |
| Output is flooded by Babylon | tests/setup-babylon.ts quiets it to warnings and errors |