Units and numerical stability

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.

The conventions (§7a, §40)

§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,
});

Fixed timesteps are the foundation (§10, §41)

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.

A worked stability example

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),
  ],
});

The §41 checklist

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).

Cross-references