Fixed timestep, random seeds, and why determinism matters.
Simulation & seeds
DemiEngine separates rendering (however fast your GPU is) from simulation (a fixed step you control). That split is why replays match, tests are stable, and networking can rewind time.
Fixed timestep
"simulation": {
"fixed_timestep": 0.016666667
}
fixed_timestep is the size of one simulation step, in seconds. The default 0.016666667 is 1/60 of a second — 60 steps per second of game time.
What runs at this rate:
on_fixed_update(fixed_dt)in every script- Physics integration (Box2D and Jolt)
- Anything that must be reproducible across machines
What runs per rendered frame:
on_update(dt)- Rendering, cameras, UI, audio triggers
If the frame rate drops to 30 FPS, simulation still steps at 60 Hz — the game does not slow down. If a frame takes long enough to need several steps, they catch up. If it takes too long, the game slows down on purpose instead of freezing in a spiral of catch-up work.
Catch-up limit
"maximum_fixed_steps_per_frame": 4
This is that safety valve (default 4, maximum 16). At 60 Hz, one frame should consume one step. If a hitch eats 200 ms, the engine would want 12 steps to catch up — it runs at most 4, then lets game time lag behind wall-clock time.
Why cap it? Without a cap, a debugger breakpoint can trigger thousands of steps and lock the game in physics for minutes. Raise the cap only if you need strict wall-clock alignment and accept the hitch cost.
Why a fixed step matters
| Without it | With it |
|---|---|
| Physics depends on frame rate — 30 FPS and 144 FPS play differently | Same results at any frame rate |
| Replays drift and desync | Replays reproduce the run |
| E2E tests flake on slow CI machines | Tests pass or fail for real reasons |
| Network prediction has no common clock | Peers step in lockstep ticks |
Write movement and combat in on_fixed_update, and sample input in on_update. That keeps gameplay deterministic while input stays responsive.
function Player:on_update(dt)
self.move_x, self.move_y = Input.vector("move") -- sample every frame
end
function Player:on_fixed_update(fixed_dt)
-- apply the last sampled input at the fixed rate
local body = require("demi.physics.rigidbody2d")
body.set_velocity(self.id, self.move_x * 6, self.move_y * 6)
end
Random seed
"simulation": {
"random_seed": 42
}
The project seed initializes the deterministic RNG (demi.math.random). Two runs of the same build with the same seed and the same input sequence produce the same rolls.
local Random = require("demi.math.random")
print(Random.value()) -- 0.0 … 1.0, sequence follows the seed
print(Random.range(2, 5)) -- float in [2, 5)
print(Random.integer(1, 6)) -- integer in [1, 6]
Use the seeded generator for anything gameplay-visible: loot rolls, procedural layout, AI coin flips. math.random is fine for purely cosmetic choices you never need to reproduce.
Snapshots and replay
Because the sequence is deterministic, you can snapshot it:
local checkpoint = Random.state() -- opaque token
Random.restore(checkpoint) -- rewind the sequence
Pair that with the input replay path and a save checkpoint, and you can rewind a run exactly. See Replay & profiler.
Changing the settings
| Goal | Setting |
|---|---|
| Smoother physics on high-refresh displays | fixed_timestep at 1/120 (0.008333333). Costs twice the steps. |
| Heavier simulation, slower machines | 1/30 (0.033333333). Coarser contacts. |
| Different daily challenge each run | Seed from the date at launch and store it in a save. |
| Identical CI replays | Keep the default seed and pin the input replay fixture. |
Change fixed_timestep rarely and treat it as a project-wide contract: input replay fixtures, network prediction, and existing saves assume the rate they were recorded at.