3.8 KiB
Replay adapter interface
The replay engine is game-agnostic: it schedules and delivers the events in a
@cozy-games/move-log envelope over time, but it never
interprets what an event means. All game meaning enters through a game
adapter — the seam defined here. This is the concrete realization of the
progress-reducer item in
ADR 0002.
ReplayAdapter<T>
An adapter is a plain object supplied at construction:
new PlaybackClock(envelope, deps, adapter)
// Typed generically over the game's event vocabulary T (and state S).
type ReplayAdapter<T> = {
progress?: ProgressReducer<T>
state?: StateReducer<T, S>
}
type ProgressReducer<T> = (events: MoveEvent<T>[]) => number // 0–100
type StateReducer<T, S> = (events: MoveEvent<T>[]) => S // full game state
MoveEvent<T> is the move-log record { seq, t, event, receivedTs? }, where
event is the game's own payload — opaque to the engine.
progress(events) → %
The only adapter method today. It maps the ordered slice of events delivered so far (every event whose offset ≤ the current playback position) to a completion percentage.
- Input:
MoveEvent<T>[]— the played-so-far slice, in order. To compute a percentage the adapter typically needs a total (e.g. total safe cells); it owns that context, usually by closing over the board it was built from. The engine passes only the slice. - Output: a number in
[0, 100]. The engine clamps the result into range and throws if the reducer returns a non-number, so an adapter can be permissive. - When: call
clock.progress()at any time. It returnsnullif no adapter (or noprogress) was supplied — the engine never invents a percentage.
// A minesweeper-style adapter, built over its board (illustrative):
const adapter = {
progress: (events) => {
const revealed = events.filter(e => e.event.type === 'reveal').length
return (revealed / totalSafeCells) * 100
}
}
const clock = new PlaybackClock(envelope, {}, adapter)
clock.seek(1500)
clock.progress() // → e.g. 42
state(events) → S (full-board mode)
The second reducer reconstructs the complete game state at a playback point from the ordered slice of events delivered so far. It powers full-board replay — rebuilding the whole board on seek, not just a percentage.
- Input:
MoveEvent<T>[]— the played-so-far slice, in order. - Output: the game's own state type
S(opaque to the engine). For mnswpr it's a 2D board snapshot:{ rows, cols, phase, revealedSafe, cells }. - When:
clock.state()returns the current reconstruction, andonStatestreams{ position, state }as playback advances or seeks.
Flag-gated
Full-board mode is off by default and gated behind a runtime flag — the engine's minimal, documented feature-flag seam:
new PlaybackClock(envelope, deps, adapter, { fullBoard: true })
When the flag is off (default), the mode is fully inert: state() returns
null, onState never fires, and the state reducer is never invoked (no
reconstruction cost). When on with a state reducer supplied, state() and
onState reconstruct the board — and seek(t) rebuilds the exact state at t.
Contract rules
- The engine calls the reducer; it never interprets events itself. Engine
source references only envelope types (
MoveEvent/MoveLog) and the log's recording metadata (seq,t) — never an event's.eventpayload. This is enforced by a guard intest/playback-clock.test.js. - The adapter owns all game meaning — event vocabulary, progress math, and (as the contract grows) state reduction and terminal predicates per ADR 0002.
- Typed generically over
Tso one engine serves every game.