fourJS has one set of unit conventions, enforced everywhere, and a body of stability guidance (§41) that exists because floating-point simulation punishes casual numbers. This guide states both, with the measured facts behind the advice.
Math.PI / 2 is a quarter turn; setFromAxisAngle(axis, Math.PI) is half.fixedTimeStep, joint damping — is seconds. There are no milliseconds anywhere in the API; the one conversion lives at the frame loop boundary ((now - last) / 1000).§40's UnitSystem record shipped 2026-08-07 in @fourjs/core (UnitSystem, SI_UNITS, resolveUnitSystem, the {angle,time,length,mass}{To,From}Display conversions, unitSymbol, and the format* helpers) — at exactly the tier §40 specifies: display and authoring-input conversion only. Declaring a unit system changes nothing the engine computes; every signature stays radians, seconds, and world units, and the conversions are inexact in their last bits, so they must never run inside a simulation path (§33–§34 — an integration test fails if any package source outside @fourjs/core imports the module). This section said "no UnitSystem API has shipped … the conversion layer is yours" until 2026-08-07. Ad-hoc constants like the one below remain perfectly fine application-side style — the helpers are the engine-blessed spelling:
// Display-side conversion helpers stay outside the simulation:
const RPM_TO_RAD_PER_S = (2 * Math.PI) / 60;
hinge.setMotor({
enabled: true,
targetVelocity: 300 * RPM_TO_RAD_PER_S, // the API takes rad/s, always
maxTorque: 400,
});
A variable-step solver trades stability for convenience and loses. fourJS steps physics at a fixed fixedTimeStep (default 1/60 s) and renders by interpolation — see the fixed-step guide. Do not shrink maximumSubSteps to "catch up" a slow machine; dropped time (TimeState.droppedTime) is the designed overload behaviour.
Damping and stiffness interact with the step size. The spring in examples/mechanism is a worked case; here is the same reasoning on a §27 drag field:
import {
ParticleEmitter,
dragField,
uniformGravityField,
} from "fourJS/particles";
import { Vector3 } from "fourJS/math";
const emitter = new ParticleEmitter({
maxParticles: 2000,
seed: 1234, // §33: seeds are constants, never entropy
position: new Vector3(0, 0, 0),
emissionRate: 400,
lifetime: { min: 1, max: 2 }, // seconds
initialSpeed: { min: 4, max: 6 }, // units per second
direction: new Vector3(0, 1, 0),
spreadAngle: 0.3, // radians — a cone half-angle
size: { start: 0.08, end: 0.02 },
color: {
start: { r: 1, g: 0.7, b: 0.3, a: 1 },
end: { r: 1, g: 0.2, b: 0.1, a: 0 },
},
fields: [
uniformGravityField(new Vector3(0, -9.81, 0)),
// Explicit-Euler drag is stable only while c·dt < 1. At 60 Hz, c = 0.35
// gives c·dt ≈ 0.0058 — far inside the bound. c = 70 would not be.
dragField(0.35),
],
});
TimeState.realTime is a double and stays precise over multi-day sessions; still prefer relative times in application math.ccdMode: "speculative" on a fast body's RigidBody prevents tunnelling and costs per-body; the default is "disabled" — leave it there for slow bodies (§31).Two measured facts worth internalizing: the accumulator's own ULP drift can move a boundary-sitting event by one step (pinned, Phase 4), and identical scenes hash identically across 2D and 3D solvers — so any divergence you see was authored, not accumulated (Phase 5 exit).
examples/mechanism — spring stiffness/damping chosen against the step; its module header derives every number.