cozy-games/packages/replay
2026-07-04 13:21:48 +02:00
..
docs feat(replay): progress reducer interface 2026-07-04 13:06:58 +02:00
test feat(replay): progress mode 2026-07-04 13:21:48 +02:00
index.js feat(replay): progress mode 2026-07-04 13:21:48 +02:00
package.json feat(replay): replay engine 2026-07-04 12:56:29 +02:00
README.md feat(replay): progress mode 2026-07-04 13:21:48 +02:00

@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() // 0100 (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.

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 (seek ahead, 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.

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.