Skip to content

Recordings and replay ​

In dev, every browser session records from boot. When something goes wrong while playing, press F8 and the session is saved to recordings/<name>.json in the game folder. A crash saves recordings/crash-<time>.json on its own. The recording replays headless, frame for frame, so the bug can be investigated with the CLI and kept as a test.

What a recording holds ​

A recording is everything that reached the simulation from outside, so a replay needs no world snapshot:

  • the seed and loop config (fixedHz, maxFrameDt, maxFixedSteps, mode);
  • every frame's delta, replayed with tick(dt) so the fixed-step accumulator splits frames exactly as it did live;
  • the input as the game saw it: action values, the stick axis, the pointer. Not raw keys, so devices and bindings don't matter;
  • side channels plugins noted between frames: DOM HUD clicks, and the frame each asset load finished on (a replay holds loads back until that frame);
  • the stored saves the session started with;
  • a state hash (entities and Rng, rounded) every 60 frames, to locate drift;
  • the final state, and the error that stopped the session, if one did.

Saving from code: __kiln_save_recording(name) in the browser console (or from Playwright) does what F8 does and resolves to the path. Without a dev server (a production build) the browser downloads the file instead. Turn recording off with record: false in kiln.config.ts.

Replaying it ​

When a user says "I recorded it", replay it before reading code:

bash
kiln replay recordings/x.json --snapshot IsPlayer,Transform --resources Score   # end state + summary
kiln replay recordings/x.json --until 300 --events Hit                      # stop where it went wrong
kiln replay recordings/x.json --every 60 --snapshot Health                  # one JSON line per 60 frames

The output is one JSON object, { replay, snapshot }:

replay field
frames, ofFrames played, and in the recording
checkedCheckpoints compared
divergedAtThe first checkpoint frame whose state differs from the session, or null
endMatches, endDiffWhether the final state matches the session's, and how it differs
recordedErrorThe error the session ended with
threw, reproducedThe replay's own error, and whether it is the recorded one at the same frame
notesAnything worth knowing (hot swaps during the session)

divergedAt: null on unchanged code means the headless replay is the session. Investigate it with --until (stop just before the problem) and --events (see what fired). The command exits 1 if the replay threw.

Keep it as a test ​

Once the bug is understood, keep the recording as a regression test that fails until the fix lands:

ts
import { expect, replay } from 'kiln/test'

replay('coin pickup no longer double-counts', 'recordings/double-coin.json', ({ app, result }) => {
  expect(app.res(Score).value).toBe(3)      // the app is live after the last frame: step it, query it
})                                           // a recorded crash re-throws at the same frame

After a fix, divergedAt is expected: the fixed code produces different state. Input still replays frame for frame, so assert on the outcome, not on matching the old session. Pass { strict: true } to fail when a checkpoint differs (for recordings of behaviour that shouldn't change), and { until: n } to stop early.

replayApp(recording, { until }) returns { app, result, replayer } to play in pieces:

ts
const { app, replayer } = await replayApp('recordings/x.json', { until: 300 })
// inspect app at frame 300 ...
await replayer.play(900)
app.dispose()

Drift ​

These make a replay drift from the browser. Drift shows as an early divergedAt on unchanged code:

  • reading anything outside ctx.time, Rng and the input plugin (Math.random, Date.now, DOM state). kiln check flags these;
  • hot reloads during the session. A system swap is listed in the recording's notes, and the replay runs today's code throughout. Saving after a full rebuild isn't possible; reload the page to start a fresh recording;
  • physics. Rapier runs the same WASM in the browser and under Bun, which usually matches, but cross-machine determinism is not guaranteed, and 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.

Read frames before divergedAt as exact and frames after it as approximate.

Plugins with outside input ​

A plugin that feeds the simulation from outside (not through the input plugin) records that input itself so replays match. See Prefabs and plugins.