Determinism
A run is decided by three things: the level, the seed and the inputs. Everything else must be derived from them, the same way on every machine and every JavaScript engine. The engine checks this after every tick with a state hash, so a broken rule shows up as a replay that no longer reproduces.
Rules for game code
- Randomness comes from
world.rng. It is seeded from the run's seed and its state is hashed and checkpointed. NeverMath.random(). - No clocks.
Date.now()andperformance.now()differ between runs. Count ticks instead (world.tick, at a fixed rate). - No engine-dependent math.
Math.sin,cos,tan,atan2,exp,log,powand their relatives (includingMath.hypotandMath.cbrt) may differ in the last bit between Chrome, Firefox, Safari and Node. Basic arithmetic (+ − × ÷),Math.sqrt,abs,min,max,floor,ceil,roundandtruncare exact everywhere. For 2D rotations userotationZ(angle)andangleZ(rotation), which use only those. - All state lives in the world. Components, resources and plugin state are hashed and checkpointed; a variable in a closure or a module is neither. If a system needs memory, give it a resource.
- Order what you iterate.
world.queryandworld.entities()are in ascending id order. If you keep your ownMaporSet, sort before iterating results into the world, and never sort withlocaleCompare(it depends on the locale). - Presentation is one-way. Systems may write
Mesh,Sprite,Cameraand other presentation components, but must not read them to decide anything: they are not hashed. - Bump
versionwhen you change a game in a way that alters runs, so old replays are reported as stale rather than broken.
What the engine guarantees
| Fixed step | One step(input) is one tick; physics steps exactly once per tick at a fixed dt |
| Ordered construction | Physics bodies are built, rebuilt and removed in ascending entity order |
| Pinned physics | Rapier 2D and 3D are pinned to 0.12.0 in the lockfile |
| Validated inputs | Every input, level, component and replay is checked by its schema |
| A hash per tick | The world (tick, ids, rng, components, resources) and each plugin's state, after every tick |
| Exact checkpoints | load re-hashes and throws if the state did not restore exactly |
How this repository checks it
- Lint.
pnpm lintbansMath.random,Math.sinand friends,Dateandperformancein the engine's simulation code (core,physics2d,physics3d,spatial, and the kit-scene runtime) and in Boo & Co.'s simulation. Add your own game's simulation folder to the same rule ineslint.config.mjs. - Property tests. Random input sequences run twice must give identical hashes.
- Saved replays.
pnpm --filter umbra-engine determinism-checkre-runs every saved replay twice and compares against its stored hash. - Node against a browser.
browser-hash-checkruns the same cases in Node and headless Chromium and compares every hash. - Agents.
playtestGamereportsnondeterministicandcheckpoint_mismatchfindings with the inputs that show them (see Agents).
Debugging a divergence
When a replay stops reproducing:
- Run it twice in the same process. If those differ, something non-deterministic is in the game:
playtestGamefinds the first tick where two runs differ. - If two runs agree with each other but not with the saved hash, the game changed since the replay was recorded. Bump
versionif the change was intended. - If Node and the browser differ, look for engine-dependent math or locale-dependent code.
- Compare
tickHashesfromrunGameto find the first differing tick, thenobserveAtthat tick in both runs and diff the JSON.
ts
import { defineGame, defineResource, runGame, z } from "umbra-engine";
const Total = defineResource("total", z.number(), () => 0);
const flaky = defineGame({
name: "flaky",
input: z.null(),
resources: [Total],
// Math.random breaks rule 1: two runs of the same replay disagree.
systems: [function add(w) { w.setResource(Total, w.resource(Total) + (Math.random() < 0.5 ? 1 : 2)); }],
});
const inputs = Array(50).fill(null);
const [a, b] = [await runGame(flaky, null, { inputs }), await runGame(flaky, null, { inputs })];
const first = a.tickHashes.findIndex((h, i) => h !== b.tickHashes[i]);
console.log(first >= 0); // true: the runs part ways at tick first + 1