Skip to main content

Replays

How Sparta’s deterministic record/playback system works, and why the logs are tiny.
Published

Last updated: 2026-11-03

Sparta records every battle so it can be re-watched and — more importantly — re-run for debugging. This is the same technique many strategy games use, and the reason such replay files stay tiny.

Two ways to build replays

Table 1: The two ways to build a replay system, and the trade-off each makes.
Approach How Trade-off
State-snapshot Save every unit’s position/health each frame, play it back like a video. Simple, but logs are large and it’s “dumb” playback — you can’t re-run the logic to investigate a bug.
Deterministic sim + input log (Sparta) Record only the RNG seed and the player’s orders (each stamped with its physics tick). Replaying re-seeds the RNG and re-injects the orders on the same ticks, so the real simulation re-runs identically. Tiny logs and reproduces bugs in the live sim.

Godot has no built-in system for either — both are something you build. Sparta uses the deterministic approach because it gives small logs and reproduces bugs in the live simulation, which is exactly what makes it useful.

Why it’s reproducible

The simulation is deterministic by construction:

  • One RNG stream. All gameplay randomness goes through Replay.rng, a single seeded RandomNumberGenerator, drawn in a stable order.
  • Fixed timestep. The sim advances on the 60 Hz physics tick — never on a variable-framerate / wall-clock timer. Enemy AI re-evaluates on a fixed tick cadence for the same reason.
  • Orders, not selections. Only right-click orders change the outcome; selection, cursor, camera, and zoom are pure presentation and never feed the simulation. They are captured separately into optional presentation tracks (camera, and the pointer: cursor / selection / drag-box / stance) so a demo clip can reproduce what the player saw and did with the mouse — but those tracks are cosmetic and a replay without them just plays the bare sim. Each order references units by a stable per-battle uid so it survives a scene reload.
  • Next-tick application. Orders are queued and applied on the next physics tick, so live play and playback take the exact same code path.
Note

Results are reproducible on the same build and platform. Bit-exact cross-platform floating-point replay is out of scope.

The file format

Replay files are small JSON:

{
  "version": 1,
  "seed": "2582915400366924141",
  "physics_tps": 60,
  "created": 1781512075.7,
  "result": "Defeat",
  "duration_ticks": 1013,
  "commit_sha": "a1b2c3d4e5f6...",
  "orders": [
    { "tick": 84,  "units": [0, 1], "x": 740.0, "y": 560.0, "target": -1 },
    { "tick": 132, "units": [3],    "x": 0.0,   "y": 0.0,   "target": 7 }
  ],
  "git_dirty_status": " M scripts/Unit.gd",
  "camera": [
    { "tick": 0, "x": 800.0, "y": 500.0, "zoom": 0.62 }
  ],
  "pointer": [
    { "tick": 84, "x": 740.0, "y": 560.0, "drag": false, "sel": [0, 1], "mode": 0 }
  ]
}

The camera and pointer arrays are optional presentation tracks (camera pan/zoom, and the mouse cursor / selection / drag-box / stance) – cosmetic, captured live, and absent from older replays. See REPLAY.md for their fields.

  • An order with target: -1 is a move (to x,y); otherwise it’s an attack on the enemy unit with that uid.
  • The seed is stored as a string on purpose: JSON numbers are float64 and would silently lose precision on a full 64-bit seed, desyncing the replay.
  • commit_sha records which commit the game was built from (scripts/BuildInfo.gd, stamped into an exported build by the release workflow). Loading a replay whose commit_sha differs from the running build’s shows a heads-up, not a hard block – most commits don’t touch anything replay-affecting. git_dirty_status is a dev-only, best-effort git status --porcelain snapshot (omitted when the checkout was clean, or on an exported build with no .git/git to query).

Using it in-game

  • Every live battle is recorded automatically (a ● REC indicator, top-center) and saved to user://replays/ when it ends.
  • Watch Replay (end screen) re-runs the battle you just played. You can pan and inspect units during playback, but can’t issue orders.
  • Load Replay (top-right, also on the end screen) opens any saved battle.
  • Fight Again starts a fresh, newly-recorded battle.

Where it lives

Table 2: The files that make up the replay system.
File Role
scripts/Replay.gd Autoload singleton: seeded RNG, record/playback, save/load
scripts/BuildInfo.gd Commit SHA stamped into a saved replay; dev-only dirty-checkout status
scripts/Battle.gd Tick clock, order queue/apply, AI cadence, saves on battle end
scripts/Unit.gd Draws combat randomness from Replay.rng; carries a uid
scripts/SelectionManager.gd Routes right-click orders to Battle.enqueue_order
scripts/HUD.gd ● REC / ▶ REPLAY indicator and the Watch Replay button

Keeping it deterministic as the game grows

When you add new systems, preserve replayability by:

  • Drawing all randomness from Replay.rng (never randf()/randi() or a fresh RandomNumberGenerator), in a deterministic order.
  • Running gameplay logic on the physics tick, not on _process/wall-clock time.
  • Recording any new player input that affects the simulation as an order (extend the command schema and bump Replay.FORMAT_VERSION).
Tip

These same replays power the project’s CI gameplay demos and the clips on this site: CI plays a replay back headlessly and encodes it to video. See demos/README.md.


Source: REPLAY.md in the repository.

Back to top