Box3D in Unreal: Determinism, Thread Counts and the Fixed Step
What Box3D promises about determinism, what the worker count does and doesn't change, and how to step it from Unreal so a sim reproduces.
Box3D is Erin Catto's 3D physics engine. It carries forward the design of Box2D v3: data-oriented C, a Soft Step solver built on sub-stepping, and a lot of care about determinism. It is MIT licensed. We've been running Box3D inside Unreal alongside Chaos, and the question that keeps coming up is how much you have to pin down to get the same answer twice.
The short answer is less than you might fear. What you do have to pin down is mostly on your side of the API.
What determinism means for a physics sim
A deterministic sim gives bit-identical results from the same inputs: same starting world, same calls in the same order, same time steps, same transforms out. "Close enough" doesn't count. Rigid body piles are chaotic, so a difference in the last bit of one contact impulse becomes a different pile a few seconds later.
That matters for a TD in several practical ways:
- Replays and recordings. Store the inputs, not every transform, and re-simulate on playback.
- Regression tests. Hash body state after N steps and fail the build when the hash moves.
- Networking. Lockstep or server-authoritative schemes rely on peers agreeing on the outcome.
- Debugging. A bug you can reproduce by replaying inputs is a bug you can fix.
What Box3D guarantees
Box3D's simulation docs say it "is designed to be deterministic across thread counts and platforms." There are three parts to that claim.
Same input, same output. The FAQ is explicit that Box3D uses no random numbers and bases no computation on timers or other random events.
Across thread counts. Simulation order is based on creation order of bodies, shapes and joints, not on which worker finishes first, and events are reported in deterministic order too. Catto's determinism post explains the approach for Box2D: the relative speed of CPU cores is a hidden source of randomness, so he avoids atomics and mutexes where he can and uses bit arrays to keep order, merged on the main thread. Box3D's own test suite runs a ragdoll pile with worker counts 1 through 5 and asserts the same final hash and the same sleep step for each.
Across platforms. On 64-bit platforms this comes from compiler settings and avoiding non-deterministic library calls. MSVC uses precise math, Clang and GCC build with -ffp-contract=off so fused multiply-add can't change rounding, and Box3D ships its own atan2, sine and cosine. The docs warn that you can break all of this with your own compiler flags, and the recording docs call -ffast-math unsupported.
Two things are not promised. The FAQ states that Box3D does not have rollback determinism: you can't set a world back to an earlier state and expect identical results, because it caches a lot of internal state. And, in the docs' words, "Box3D determinism does not mean your application will be deterministic."
So where does the thread count come in?
The worker count changes how the work is split, not the answer. In solver.c the solver cuts each stage into blocks sized from the worker count (about four blocks per worker, so idle workers can steal) and workers claim blocks as they go. The docs for the replay player describe it this way: replaying at a different worker count than was recorded "re-partitions the constraint graph, so the state-hash check becomes a cross-thread determinism test." It is a test, and one Box3D is designed to pass.
At least one community integration still plays it safe. Antonio Lattanzio's Box3DUnreal plugin (MIT) defaults its WorkerCount to 1. A comment in the header says Box3D partitions the constraint graph by worker count, so a different count changes the result, and that the setting should be kept at 1 for server authority or rollback. That is a stricter reading than Box3D's own docs and tests. As policy it is still reasonable: pinning the count explicitly means a later "turn on threads for perf" change can't quietly become the variable in a divergence hunt.
Our practical rule is to treat the worker count as part of a run's configuration. Record it with the run and hold it fixed when you compare, even though the engine is built not to care. If a replay diverges only when the count changes, you have found a real engine bug worth reporting, not a setting to fiddle with.
The things that change the answer for certain are on your side:
- The time step and sub-step count.
b3World_Step(worldId, timeStep, subStepCount)wants a fixed step. The header says so: "this should be a fixed number. Usually 1/60." - The precision mode. Box3D can be built with double-precision world positions for large worlds. That is a large-world feature, not a determinism feature: the double and float builds are each deterministic, but they produce different hashes. Every machine that has to agree needs the same mode, the same build flags and the same library build.
- Creation order and call order. Create bodies in a stable order and apply forces in a stable order.
TMapiteration and actor spawn order are the usual suspects in Unreal.
Stepping Box3D from Unreal's frame loop
Unreal's DeltaTime varies from frame to frame, and Box3D wants a constant step. The standard fix is an accumulator: add frame time, step in fixed slices while enough time is banked, clamp the bank so a hitch can't cause a spiral of death, and interpolate transforms for rendering using what's left over. Box3DUnreal does exactly this in its subsystem's Tick, with a MaxFrameTime clamp and an interpolation alpha.
A minimal sketch in plain C, using the names from Box3D's public headers:
#include "box3d/box3d.h"
static b3WorldId worldId;
void InitPhysics( void )
{
b3WorldDef def = b3DefaultWorldDef();
def.gravity = (b3Vec3){ 0.0f, 0.0f, -9.8f }; // Box3D has no up axis; meters, not cm
def.workerCount = 4; // >1 with no task callbacks uses Box3D's internal scheduler
worldId = b3CreateWorld( &def );
}
const float fixedStep = 1.0f / 60.0f;
const int subStepCount = 4;
const double maxFrameTime = 0.25;
static double accumulator = 0.0;
void OnFrame( double frameDeltaSeconds )
{
accumulator += frameDeltaSeconds;
if ( accumulator > maxFrameTime )
{
accumulator = maxFrameTime; // drop time rather than spiral
}
while ( accumulator >= fixedStep )
{
// apply this step's inputs here, in a stable order
b3World_Step( worldId, fixedStep, subStepCount );
accumulator -= fixedStep;
}
float alpha = (float)( accumulator / fixedStep ); // blend previous/current for rendering
}To plug in Unreal's task graph instead of the internal scheduler, b3WorldDef has enqueueTask, finishTask and userTaskContext. Read the b3FinishTaskCallback comment before you do: b3World_Step blocks on the tasks it spawns, so calling it from inside a job that can't yield can deadlock. Unreal uses centimeters and Box3D assumes meters, so convert at the boundary.
Note that the clamp drops time on a hitch. The sim stays deterministic, because it is a function of the step count, but it slips against wall-clock time. For a replay, record steps and per-step inputs, not frame deltas.
Recording, and the rollback caveat
Box3D has built-in recording and replay. b3CreateRecording and b3World_StartRecording capture a snapshot plus every world-changing call. b3ValidateReplay re-runs a recording headless and reports whether it diverged. b3CreatePlayer steps through one frame by frame, and b3RecPlayer_SetWorkerCount lets you replay at a different count. It doubles as a regression harness.
For rollback netcode, be careful. The snapshot machinery behind recordings is internal, there is no public save/restore API yet, and the FAQ disclaims rollback determinism. Lockstep and server-authoritative designs fit what Box3D promises today. Rollback is still something you would be building yourself.
Further reading
- Box3D on GitHub by Erin Catto: see
docs/simulation.md(Determinism),docs/faq.md,docs/recording.mdandtest/test_determinism.c - Determinism by Erin Catto, box2d.org, August 2024
- Solver2D by Erin Catto, box2d.org, February 2024: the background to the Soft Step solver
- Box3DUnreal by Antonio Lattanzio: a community Unreal integration with a fixed-step subsystem



