Overview
What’s recorded
Section titled “What’s recorded”Forge replays are action streams, not raw event streams. The recorder watches the action state on every advance() (after refresh) and writes one event per transition:
| Action kind | Event |
|---|---|
| Digital false→true | { kind: "press", action, tick } |
| Digital true→false | { kind: "release", action, tick } |
| Axis value change | { kind: "axis", action, value, tick } |
Axis changes use a 1e-6 epsilon — minor float jitter doesn’t bloat the file.
Why action streams?
Section titled “Why action streams?”Three reasons:
- Size. A 60-second replay typically has tens-to-hundreds of frames vs. tens of thousands of raw events.
- Hardware-independent. Same recording plays whether the user used WASD or a gamepad.
- Forward-compatible. Add a new mouse axis next year — old replays still work because they record
move.x, notMouseMove(45, 91).
Recording
Section titled “Recording”replay.record(input, ctx, opts?) reads seed, fixed_dt, and get_tick from the Ctx. This is the form every game uses:
import { harness, replay } from "@f0rbit/forge";import { presets } from "@f0rbit/forge/presets";
const h = harness({ seed: 1, fixed_dt: 1/60, bindings: presets.movement_2d });const recorder = replay.record(h.input, h.ctx);
// drive the sim however you like — input.inject_actions, scripted source, etc.h.input.inject_actions([{ kind: "axis", action: "move.x", value: 1 }]);for (let i = 0; i < 600; i++) { h.time.advance(1/60); h.schedule.tick(h.world, h.ctx);}
const doc = recorder.stop(); // detaches listeners, returns ReplayDocA low-level overload replay.record(input, { seed, fixed_dt, get_tick }) is available for unit tests that don’t have a Ctx. Both forms return the same Recorder.
recorder.dump() snapshots without stopping (so you can keep recording).
Playback
Section titled “Playback”const doc: ReplayDoc = /* ... loaded from disk ... */;const player = replay.play(doc, input, () => time.tick);
while (!player.complete()) { time.advance(doc.fixed_dt); schedule.tick(world, ctx);}
player.detach();play registers an on_pre_advance listener. On every tick, before the source drain runs, the player calls input.inject_actions(events_for_this_tick). Injected actions take precedence over the source for that tick, so the world evolves identically to the original recording — provided seed, code, and fixed_dt match.
player.complete() returns true once the player has drained the last recorded frame. player.detach() removes the listener (call this if you switch from playback to live input mid-session).