5.7 KiB
@cozy-games/replay
A game-agnostic replay engine. PlaybackClock re-drives a
@cozy-games/move-log envelope over time — scheduling each
recorded event to fire at its offset — with play / pause / seek.
import { PlaybackClock } from '@cozy-games/replay'
const clock = new PlaybackClock(envelope) // a valid move-log envelope
const off = clock.on(record => apply(record.event)) // record = { seq, t, event, ... }
clock.play() // events fire at their recorded offsets
clock.pause() // freeze at the current position
clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500
Progress via a game adapter
The engine never interprets events. To show completion percent, supply a game
adapter with a progress(events) → % reducer at construction:
const adapter = { progress: (events) => (events.length / total) * 100 }
const clock = new PlaybackClock(envelope, {}, adapter)
clock.seek(1500)
clock.progress() // 0–100 (clamped), or null if no adapter supplied
The reducer receives the ordered slice of events delivered so far; the engine clamps the result and stays blind to the payload. See docs/adapter-interface.md for the full contract.
Progress mode: a signal over time
onProgress turns the clock + reducer into a live "percent complete over elapsed
time" signal — updates as playback advances, on seek forward, and on seek back:
const clock = new PlaybackClock(envelope, {}, adapter)
clock.onProgress(({ position, progress }) => draw(position, progress))
clock.play()
Fidelity is exact: at playback time t the delivered set is precisely the events
at offset ≤ t, so the emitted progress matches the original run's progress at
t. Updates fire only when the percentage moves (a flag/unflag emits nothing).
Subscribe before playing to catch every update; call progress() for the current
value at any time.
Full-board mode (flag-gated)
With a state reducer and the fullBoard flag, the engine reconstructs the whole
board at any point — state() for the current board, onState for a stream, and
seek(t) rebuilds the exact state at t:
const clock = new PlaybackClock(envelope, {}, adapter, { fullBoard: true })
clock.onState(({ position, state }) => render(state))
clock.seek(1500) // state() now reflects the board at 1500ms
Off by default: without the flag, state() is null, onState never fires, and
the reducer never runs. See docs/adapter-interface.md.
The "ended" signal & partial recordings
onEnd fires with { position } when playback reaches the last recorded
event's offset:
clock.onEnd(({ position }) => showEndScreen())
The engine has no notion of a "terminal" event, so this is the same signal whether the run completed or the recording was truncated mid-game. A partial log plays up to its last event and stops cleanly in both progress and full-board modes — no error for the mere absence of a terminal event. Progress freezes at its last computed value (no extrapolation, no jump to 100%), because it's purely the reducer over the delivered events. The signal re-arms if you seek back before the end.
Offsets
Each event fires at its offset — its recorded t minus the first event's
t, so playback time 0 is the first event. duration is the last event's
offset.
Injected clock + scheduler
The time source and scheduler are injected (mirroring the core session's injected-clock seam), so tests get exact, deterministic timing:
new PlaybackClock(envelope, { clock, setTimeout, clearTimeout })
They default to the real host (Date.now + global timers). Under a deterministic
injected scheduler — or vi.useFakeTimers() — events fire exactly at their
offsets (tolerance 0). Under the real host scheduler the tolerance is the host's
timer resolution (a few ms), the same bound as any setTimeout.
Seek is deterministic
The clock keeps one invariant: cursor = the number of events whose offset is
<= position. So after seek(t) the delivered set is exactly the events at
offset <= t:
- Forward (
seekahead, or playback advancing) delivers each newly-passed event once, in order. - Backward rewinds the cursor without delivering; passing those offsets again going forward re-delivers them (so scrub-back-then-replay works).
No event is ever delivered twice for a single forward pass, and none is dropped.
Schema-version dispatch
One engine build replays envelopes from multiple format generations. On
construction the envelope is routed by its schema_version through a dispatch
table to a version-specific reader that normalizes it into the canonical
MoveEvent records the engine plays:
readEnvelope(envelope) // → canonical records, or throws
-
v1 is the only built-in reader today (the canonical format itself).
-
Unknown versions fail loudly — never a silent best-effort parse:
readEnvelope: unsupported envelope schema_version 99 (supported: 1) -
Adding a generation is one entry. Register a reader for this instance via the
readersoption (or add to the built-in table for a permanent version):const v2 = (env) => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload })) new PlaybackClock(v2Envelope, {}, adapter, { readers: { 2: v2 } })Whatever a reader returns is validated as a canonical move log, so a half-normalized generation can never reach the engine.
Invariant: envelope only, no game types
This module imports only @cozy-games/move-log (to validate the envelope) and
never a game package. It treats every event payload as opaque. Enforced by a
dependency-graph guard in test/playback-clock.test.js.