Skip to content

Determinism ​

The same seed and the same input give the same world, step for step. That is what makes scenario tests exact, kiln run repeatable, and recorded play sessions replayable. It holds as long as the simulation only reads three things from outside: the clock in ctx.time, the seeded Rng, and input through the input plugin.

Randomness: Rng ​

ts
const rng = res.mut(Rng)
rng.float()             // [0, 1)
rng.float(-5, 5)        // [-5, 5)
rng.int(1, 6)           // 1..6 inclusive
rng.chance(0.25)        // true a quarter of the time
rng.pick(['a', 'b'])    // a random element
rng.shuffle(items)      // in place

Rng is seeded from seed in kiln.config.ts. Scenarios can override it (scenario(name, fn, { seed: 3 })), and so can the CLI (kiln run --seed 3, kiln shot --seed 3). Its state is part of snapshots and recordings.

For a stream of its own (a particle emitter, a level generator), seed a new RngState from the app's: new RngState(res.mut(Rng).u32()).

Time: ctx.time ​

Field
time.stepThe fixed step, 1 / fixedHz
time.dtstep inside Fixed; the clamped frame delta elsewhere
time.elapsedSimulated seconds: tick * step
time.tickFixed steps run so far
time.frameFrames run so far
time.alphaInterpolation between the last two fixed steps

Count steps instead of adding up floats: subtracting time.step 3600 times leaves about 1e-12, so a remaining <= 0 check fires one step late. stepsFor(seconds, time.step) converts a duration to steps, and the built-in Timer, Lifetime and Delayed all count steps.

kiln check ​

bash
kiln check
src/systems.ts:42:15 no-math-random Use res.mut(Rng).float() (seeded) instead of Math.random().
src/systems.ts:57:20 no-date-now Use ctx.time.elapsed (simulated seconds) instead of Date.now().

It flags Math.random(), Date.now(), performance.now() and new Date() in src/ (vendored plugins included) and exits 1 if it finds any. Add kiln-allow in a comment on a line that needs one on purpose, such as a timestamp in a save file name.

Where determinism ends ​

  • Physics. Rapier is deterministic on one machine, and runs the same WASM in the browser and under Bun, which usually matches. Cross-machine determinism is not guaranteed.
  • Floating point across engines. Math.sin, cos and pow can differ in the last bit between Chrome's V8 and Bun's JavaScriptCore. Long, physics-heavy replays can drift because of it.
  • Rendering and cosmetics. Update and Render run once per frame with a variable dt, so anything they compute depends on the frame rate. Keep gameplay in Fixed.

Replays report the first frame where state stopped matching (divergedAt), so drift shows up as a number rather than a mystery. See Recordings and replay.