Determinism and Replay¶
What is guaranteed¶
Given the same templates, level, SimConfig, seed, registered extensions, initial spawns and the same commands at the same ticks, the simulation produces the same state on the same runtime – regardless of frame rate or game speed (1×, 2×, 3×).
How the core achieves it:
- Fixed ticks (
SimConfig.TickRate, default 30); speed only changes how many ticks run per frame. - One seeded random stream,
Simulation.Random(SimRandom, xorshift32). The seed isSimulation.Seed. - Stable ordering: entity Ids only increase, systems run in a fixed order, damage is applied in request order, ties break on the lower Id.
- Commands are applied only at the start of a tick.
- Spawns and removals are committed at the end of a tick.
What is not guaranteed: bit-identical floating-point results across different CPUs, runtimes or compiler settings. Use the state hash to check reproducibility on your target platform.
Things that break determinism¶
SimulationRunnerwith Randomize Seed enabled (by design).- Extension code that uses
UnityEngine.Random,System.Random, wall clock time, frame time orDictionary/HashSetiteration order in the tick. - Changing simulation state from outside
Step()other than throughEnqueue. - Registering extensions differently between the original run and the replay.
State hash¶
ulong hash = sim.ComputeStateHash(); // call between ticks, never inside Step
The 64-bit FNV-1a hash covers the seed and random state, tick, phase, gold, lives, wave state, slot occupancy, every entity's authoritative state in Id order, pending spawns/removals, effects, scheduled summons, spells, queued commands and queued damage, upgrade snapshots, deployment reservations, companion slots, revival/body records, plus the state of extensions that implement IStateHashContributor. It excludes events and the render-frame accumulator.
Typical uses: compare a live run with its replay, compare 1× vs 3× runs, or detect divergence in automated checks.
Recording commands¶
using System.IO;
using TDMaker.Core;
var log = new CommandLog(sim.Seed, capacity: 4096);
sim.StartRecording(log); // before the first Step and before enqueueing anything
// … play: sim.Enqueue(...) / sim.Step() …
sim.StopRecording(); // between ticks; seals the log
using (var file = File.Create(path))
log.WriteTo(file); // pass an ICommandCodec as second argument if you use custom commands
- Only commands enqueued from outside
Step()are recorded, each with the tick at which it will execute. Commands created inside the tick by systems are regenerated by those systems during replay. - The log has a fixed capacity; when full, further commands are rejected with
CapacityExceeded(it never grows during play). - Custom commands need an
ICommandCodecwith type ids ≥ 1024 (see Extending the Framework).
Replaying¶
CommandLog log;
using (var file = File.OpenRead(path))
log = CommandLog.ReadFrom(file); // or ReadFrom(file, codec)
var replay = new Simulation(templates, level, config, log.Seed);
// register exactly the same extensions and spawn the same initial units (e.g. heroes) here
replay.StartReplay(log);
while (replay.State.Phase == GamePhase.Running)
{
replay.Step();
replay.ClearEvents();
}
- During replay, live
Enqueuecalls from outsideStep()throw. - With
SimulationRunner, heroes are spawned bySpawnHeroeswhenSpawn Heroesis enabled; do the same before replaying (SimulationRunner.SpawnHeroes(sim, baked)). - Replay throws if the log skips an execution tick or if the input capacity differs from the recording.
A replay must use the same permanent upgrade snapshot as the recording. The runner writes purchased ranks beside a command log in <file>.upgrades.json; changing the shop or ranks changes the fingerprint and replay is refused. See Campaigns, Progress and Saves.
Recording and replaying with SimulationRunner¶
The runner's Recording & Replay section does this for a Unity session:
| Field | Meaning |
|---|---|
Record Commands |
Record every run (started before the first step and before any input). |
Recording Capacity |
Commands one recording can hold. |
Recording File |
Where the log is written when the game ends, on restart and when the runner is disabled. Relative paths use Application.persistentDataPath. Empty = keep it in memory only (runner.CommandLog). |
Replay On Start / Replay File |
Start runs by replaying a saved log (its seed is used); live runner.Enqueue calls are ignored while IsReplaying. |
From code: RecordCommands, RecordingFile, ReplayOnStart, ReplayFile, CommandLog, IsReplaying, SaveRecording(). Set runner.CommandCodec before the run if you use custom commands. Register the same extensions in the replaying scene (for example the same SimulationStarted handlers and template decorators).
Speed-independence check¶
ulong RunAt(float speed) { /* create sim, start replay, advance with SimulationClock at 'speed' until done */ return sim.ComputeStateHash(); }
// RunAt(1) == RunAt(2) == RunAt(3)
Record one input sequence, load it into otherwise identical simulations and advance each with SimulationClock at a different speed. Compare the final state hashes on the same runtime to verify your content and extensions.