Skip to content

ADR-011: Replays record what the player did; the simulation does the rest

  • Status: Accepted (2026-10-20), proposed 2026-10-18 on the bowling spike. Inputs that land mid-motion use the timeline of ADR-014; the tabletop’s replays are the second case.
  • Evidence: packages/effect-three-samples/src/bowling/replay.ts, test/bowling-replay.test.ts, and headless browser runs (Chromium, SwiftShader): a game of six rolls with varied start, line and speed, each rolled after a random pause, replayed from its link in a fresh page and again in the same page. Every roll knocked down the same pins, and the hardest contact force matched to the newton. Two separately played games with different pauses gave identical results (6, 1, 4, 2, 6, 0): the outcome is a function of the inputs, not of when they came.
  • References: Rapier’s determinism notes (rapier.rs, “Determinism”): with the same build, the same inputs at the same steps give the same results; the *-deterministic builds extend that across platforms.

Context

A Foldkit Model is the whole truth of an application, and every change to it is a Message. Replaying the Messages replays the application. A scene with physics is different: where a body is belongs to the simulation, not the Model, and the events the simulation reports (Collision, ContactForce, and the sensor events bowling counts pins with) are Messages the Model receives but did not cause. Replaying those would replay what happened without the world that made it happen.

Shareable runs, ghost replays, reproducible bug reports and lockstep multiplayer all want the same thing: a run reproduced from a small record.

Decision (proposed)

  • Record intent, not outcome. A replay is the player’s inputs (for bowling, each roll’s start, line and speed), never the events the simulation produced from them. Replaying feeds the inputs back and lets the simulation produce the events again.
  • Make each input start from a state that does not depend on timing. Physics steps are fixed, so the world is a function of its inputs and the steps between them. The number of steps between two inputs depends on how long the player took, so an input must meet a world that has stopped changing:
    • bodies an input sets in motion are created by it, with their impulse (a fresh body carries no history), rather than pushed after sitting for a while;
    • an input is accepted only once the world it acts on has come to rest: Rapier puts resting bodies to sleep after two seconds, and asleep they do not change at all.
  • Check the replay against the record. The record keeps the outcomes too (pins knocked by each roll); a replay compares what it produced with them, so a divergence is found, not assumed away.
  • The record is data in the address. Inputs and outcomes encode into a short fragment (#replay=…), decoded and validated before use: a link is a replay.

Consequences

  • Replays are exact on the same build, across page loads; across machines they rely on WebAssembly’s deterministic floating point and, to be guaranteed, on Rapier’s deterministic build (@opsydyn/effect-three-rapier/deterministic since ADR-014).
  • Waiting for rest costs the player a moment between rolls; it is what makes the record small. Inputs that must land mid-motion (a flipper, a steering wheel) need the step count recorded with them, and the engine to apply inputs at step boundaries: the next step towards lockstep multiplayer. (Since built: physics({ until }) and Timeline, ADR-014, which a replay can use for inputs at any step.)
  • Foldkit DevTools time travel shows the Model at a past Message but not the physics world then; an event-sourced replay re-simulates instead. Checkpointing the world (Rapier can snapshot it) would join the two.

Since: replays on the timeline (2026-10-20)

Inputs that land at any moment, not only at rest, are recorded with their step: a Timeline (ADR-014) is already that record. The tabletop’s “Replay this game” link carries every throw and rebuild with its step, the last check step the world reached, and the world’s checksums at its last thirty checks (#replay=…, the record’s JSON in URL-safe base64, decoded by its schema before use). Opening it replays without a session: the throws go into a timeline at their steps, the frontier moves a turn’s steps each tick (so the game plays back at the pace it was played), and the record’s checksums wait to be compared as the world reaches their steps, the same comparison peers make. Evidence: test/tabletop.test.ts (a record through the address and back unchanged, a replay meeting every check, a forged one found out at its first), and headless Chromium with Rapier’s deterministic build: a game of five throws and a rebuild, replayed from its link in a fresh browser context, met all 14 of its checks; a damaged link is not a record, and the page plays live instead.