Appearance
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 framesThe output is one JSON object, { replay, snapshot }:
replay field | |
|---|---|
frames, of | Frames played, and in the recording |
checked | Checkpoints compared |
divergedAt | The first checkpoint frame whose state differs from the session, or null |
endMatches, endDiff | Whether the final state matches the session's, and how it differs |
recordedError | The error the session ended with |
threw, reproduced | The replay's own error, and whether it is the recorded one at the same frame |
notes | Anything 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 frameAfter 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,Rngand the input plugin (Math.random,Date.now, DOM state).kiln checkflags 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,cosandpowcan 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.