DEMIENGINEDOCUMENTATION Everything is free
Project & file formats · Start here

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 itWith it
Physics depends on frame rate — 30 FPS and 144 FPS play differentlySame results at any frame rate
Replays drift and desyncReplays reproduce the run
E2E tests flake on slow CI machinesTests pass or fail for real reasons
Network prediction has no common clockPeers 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

GoalSetting
Smoother physics on high-refresh displaysfixed_timestep at 1/120 (0.008333333). Costs twice the steps.
Heavier simulation, slower machines1/30 (0.033333333). Coarser contacts.
Different daily challenge each runSeed from the date at launch and store it in a save.
Identical CI replaysKeep 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.