import type { DefinedNetworkedGame } from "./define-networked-game.js"; import { NetworkedAuthoritativeEngine, type NetworkedServerStepResult, type NetworkedSimulationCheckpoint, } from "./networked-server.js"; import type { NetworkedGameDefinition, SnapshotBatch } from "./networked-types.js"; import type { ClientStateReport, InputPacket, PlayerId, StateSnapshot, ValidationResult, } from "./types.js"; import type { InputDecision, ServerEngineOptions } from "./server.js"; export type ReplayStateHash = string | number; export type ReplayCommand = | { kind: "add-player"; atTick: number; order: number; playerId: PlayerId; } | { kind: "remove-player"; atTick: number; order: number; playerId: PlayerId; } | { kind: "input"; atTick: number; order: number; playerId: PlayerId; packet: InputPacket; }; export interface ReplayVerification { tick: number; hash: StateHash; } export interface ReplayCheckpoint< AuthorityState, Input, AuthorityEvent, StateHash extends ReplayStateHash, > { tick: number; commandCount: number; hash: StateHash; simulation: NetworkedSimulationCheckpoint< AuthorityState, Input, AuthorityEvent >; authorityEvents: AuthorityEvent[]; } /** * A recording contains private authority checkpoints and must remain on trusted * infrastructure. Use ReplaySession.viewAs() for a privacy-filtered client view. */ export interface ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash extends ReplayStateHash, > { version: 1; seed: Seed; tickRateHz: number; snapshotRateHz: number; /** First seekable tick. Rolling recordings may start after tick zero. */ startTick: number; durationTicks: number; serverOptions: ServerEngineOptions; commands: ReplayCommand[]; verifications: ReplayVerification[]; checkpoints: Array< ReplayCheckpoint >; } export interface TimeTravelDefinition< AuthorityState, Seed, StateHash extends ReplayStateHash, > { createSeed(): Seed; initializeState?(state: AuthorityState, seed: Seed): void; cloneSeed?(seed: Seed): Seed; hashState(state: Readonly): StateHash; checkpointIntervalTicks?: number; verificationIntervalTicks?: number; } export interface TimeTravelServerOptions extends ServerEngineOptions { replaySeed?: Seed; /** * Retain only this many recent ticks in the private authority recording. * The actual window can be up to one checkpoint interval larger. */ recordingHistoryTicks?: number; } export interface ReplayFrame< AuthorityState, AuthorityEvent, StateHash extends ReplayStateHash, > { tick: number; state: AuthorityState; authorityEvents: AuthorityEvent[]; hash: StateHash; } export class ReplayDivergenceError< StateHash extends ReplayStateHash, > extends Error { readonly tick: number; readonly expected: StateHash; readonly actual: StateHash; constructor(tick: number, expected: StateHash, actual: StateHash) { super( `Replay diverged at tick ${tick}: expected ${String(expected)}, received ${String(actual)}`, ); this.name = "ReplayDivergenceError"; this.tick = tick; this.expected = expected; this.actual = actual; } } interface NormalizedTimeTravelDefinition< AuthorityState, Seed, StateHash extends ReplayStateHash, > extends TimeTravelDefinition { checkpointIntervalTicks: number; verificationIntervalTicks: number; } interface ReplayTimeline< AuthorityState, Input, AuthorityEvent, Seed, StateHash extends ReplayStateHash, > { seed: Seed; serverOptions: ServerEngineOptions; commands: ReplayCommand[]; verifications: ReplayVerification[]; checkpoints: Array< ReplayCheckpoint >; } export type TimeTravelNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, > = Omit< DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, "createServer" > & { createServer( options?: TimeTravelServerOptions, ): TimeTravelAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >; createReplay( recording: ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash >, ): ReplaySession< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash >; }; export class TimeTravelAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, > { readonly game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >; private readonly engine: NetworkedAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >; private readonly timeTravel: NormalizedTimeTravelDefinition< AuthorityState, Seed, StateHash >; private readonly seed: Seed; private readonly serverOptions: ServerEngineOptions; private readonly recordingHistoryTicks: number | null; private commands: ReplayCommand[]; private verifications: ReplayVerification[]; private checkpoints: Array< ReplayCheckpoint >; private nextOrder: number; constructor( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, engine: NetworkedAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, timeTravel: NormalizedTimeTravelDefinition< AuthorityState, Seed, StateHash >, seed: Seed, serverOptions: ServerEngineOptions, recordingHistoryTicks: number | null, timeline?: ReplayTimeline< AuthorityState, Input, AuthorityEvent, Seed, StateHash >, ) { this.game = game; this.engine = engine; this.timeTravel = timeTravel; this.seed = cloneSeed(timeTravel, seed); this.serverOptions = { ...serverOptions }; this.recordingHistoryTicks = recordingHistoryTicks; if (timeline) { this.commands = timeline.commands.map((command) => cloneCommand(game, command), ); this.verifications = timeline.verifications.map((entry) => ({ ...entry })); this.checkpoints = timeline.checkpoints.map((checkpoint) => cloneReplayCheckpoint(game, checkpoint), ); this.nextOrder = this.commands.reduce((maximum, command) => Math.max(maximum, command.order), -1) + 1; } else { this.commands = []; const hash = timeTravel.hashState(engine.currentState); this.verifications = [{ tick: engine.tick, hash }]; this.checkpoints = [ { tick: engine.tick, commandCount: 0, hash, simulation: engine.createSimulationCheckpoint(), authorityEvents: [], }, ]; this.nextOrder = 0; } } get tick(): number { return this.engine.tick; } get currentState(): Readonly { return this.engine.currentState; } get playerIds(): PlayerId[] { return this.engine.playerIds; } addPlayer(playerId: PlayerId): void { const existed = this.engine.playerIds.includes(playerId); this.engine.addPlayer(playerId); if (!existed && this.engine.playerIds.includes(playerId)) { this.recordCommand({ kind: "add-player", atTick: this.tick, order: this.nextOrder, playerId, }); } } removePlayer(playerId: PlayerId): void { const existed = this.engine.playerIds.includes(playerId); this.engine.removePlayer(playerId); if (existed && !this.engine.playerIds.includes(playerId)) { this.recordCommand({ kind: "remove-player", atTick: this.tick, order: this.nextOrder, playerId, }); } } submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { const decision = this.engine.submitInput(playerId, packet); if (decision.accepted) { this.recordCommand({ kind: "input", atTick: this.tick, order: this.nextOrder, playerId, packet: cloneInputPacket(this.game, packet), }); } return decision; } submitStateReport( playerId: PlayerId, report: ClientStateReport, ): ValidationResult | null { return this.engine.submitStateReport(playerId, report); } step(): NetworkedServerStepResult { const result = this.engine.step(); const hash = this.timeTravel.hashState(this.engine.currentState); if (result.tick % this.timeTravel.verificationIntervalTicks === 0) { this.verifications.push({ tick: result.tick, hash }); } if (result.tick % this.timeTravel.checkpointIntervalTicks === 0) { this.checkpoints.push({ tick: result.tick, commandCount: this.commands.length, hash, simulation: this.engine.createSimulationCheckpoint(), authorityEvents: [...result.events], }); } this.pruneRecording(); return result; } createSnapshot( playerId: PlayerId, serverTime: number, ): StateSnapshot { return this.engine.createSnapshot(playerId, serverTime); } createSnapshotBatches(serverTime: number): SnapshotBatch[] { return this.engine.createSnapshotBatches(serverTime); } createPerceptions( playerId: PlayerId, events: readonly AuthorityEvent[], ): PerceptionEvent[] { return this.engine.createPerceptions(playerId, events); } exportRecording(): ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash > { return cloneRecording(this.game, this.timeTravel, { version: 1, seed: this.seed, tickRateHz: this.game.tickRateHz, snapshotRateHz: this.game.snapshotRateHz, startTick: this.checkpoints[0]?.tick ?? this.tick, durationTicks: this.tick, serverOptions: this.serverOptions, commands: this.commands, verifications: this.verifications, checkpoints: this.checkpoints, }); } private recordCommand(command: ReplayCommand): void { this.commands.push(cloneCommand(this.game, command)); this.nextOrder += 1; } private pruneRecording(): void { if (this.recordingHistoryTicks === null) return; const cutoff = this.tick - this.recordingHistoryTicks; if (cutoff <= 0 || this.checkpoints.length < 2) return; let baselineIndex = 0; for (let index = 1; index < this.checkpoints.length; index += 1) { const checkpoint = this.checkpoints[index]!; if (checkpoint.tick > cutoff) break; baselineIndex = index; } if (baselineIndex === 0) return; const baseline = this.checkpoints[baselineIndex]!; const removedCommandCount = baseline.commandCount; this.commands = this.commands.slice(removedCommandCount); this.verifications = this.verifications.filter( ({ tick }) => tick >= baseline.tick, ); this.checkpoints = this.checkpoints.slice(baselineIndex).map( (checkpoint) => ({ ...checkpoint, commandCount: checkpoint.commandCount - removedCommandCount, }), ); } } export class ReplaySession< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, > { private readonly game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >; private readonly timeTravel: NormalizedTimeTravelDefinition< AuthorityState, Seed, StateHash >; private readonly recording: ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash >; private readonly verifications: Map; private engine: NetworkedAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >; private commandIndex = 0; private latestAuthorityEvents: AuthorityEvent[] = []; constructor( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, timeTravel: NormalizedTimeTravelDefinition< AuthorityState, Seed, StateHash >, recording: ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash >, ) { validateRecording(game, recording); this.game = game; this.timeTravel = timeTravel; this.recording = cloneRecording(game, timeTravel, recording); this.verifications = new Map( this.recording.verifications.map(({ tick, hash }) => [tick, hash]), ); this.engine = createSeededEngine( game, timeTravel, this.recording.seed, this.recording.serverOptions, ); this.seek(this.recording.startTick); } get tick(): number { return this.engine.tick; } get durationTicks(): number { return this.recording.durationTicks; } get startTick(): number { return this.recording.startTick; } get currentState(): Readonly { return this.engine.currentState; } seek( targetTick: number, ): ReplayFrame { if ( !Number.isInteger(targetTick) || targetTick < this.recording.startTick || targetTick > this.recording.durationTicks ) { throw new RangeError( `target tick must be between ${this.recording.startTick} and ${this.recording.durationTicks}`, ); } const checkpoint = this.findCheckpoint(targetTick); this.engine = createSeededEngine( this.game, this.timeTravel, this.recording.seed, this.recording.serverOptions, ); this.engine.restoreSimulationCheckpoint(checkpoint.simulation); this.commandIndex = checkpoint.commandCount; this.latestAuthorityEvents = [...checkpoint.authorityEvents]; this.assertHash(checkpoint.tick, checkpoint.hash); this.applyCommandsAtCurrentTick(); while (this.engine.tick < targetTick) { const result = this.engine.step(); this.latestAuthorityEvents = [...result.events]; const expected = this.verifications.get(result.tick); if (expected !== undefined) this.assertHash(result.tick, expected); this.applyCommandsAtCurrentTick(); } return this.frame(); } frame(): ReplayFrame { return { tick: this.engine.tick, state: this.game.server.cloneState(this.engine.currentState), authorityEvents: [...this.latestAuthorityEvents], hash: this.timeTravel.hashState(this.engine.currentState), }; } viewAs( playerId: PlayerId, serverTime = 0, ): StateSnapshot { if (!this.engine.playerIds.includes(playerId)) { throw new RangeError(`player ${playerId} does not exist at tick ${this.tick}`); } return this.engine.createSnapshot(playerId, serverTime); } perceptionsAs(playerId: PlayerId): PerceptionEvent[] { if (!this.engine.playerIds.includes(playerId)) { throw new RangeError(`player ${playerId} does not exist at tick ${this.tick}`); } return this.engine.createPerceptions(playerId, this.latestAuthorityEvents); } branch( atTick = this.tick, ): TimeTravelAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash > { this.seek(atTick); const branchCheckpoint: ReplayCheckpoint< AuthorityState, Input, AuthorityEvent, StateHash > = { tick: atTick, commandCount: this.commandIndex, hash: this.timeTravel.hashState(this.engine.currentState), simulation: this.engine.createSimulationCheckpoint(), authorityEvents: [...this.latestAuthorityEvents], }; const branchEngine = createSeededEngine( this.game, this.timeTravel, this.recording.seed, this.recording.serverOptions, ); branchEngine.restoreSimulationCheckpoint(branchCheckpoint.simulation); return new TimeTravelAuthoritativeEngine( this.game, branchEngine, this.timeTravel, this.recording.seed, this.recording.serverOptions, null, { seed: this.recording.seed, serverOptions: this.recording.serverOptions, commands: this.recording.commands.slice(0, this.commandIndex), verifications: this.recording.verifications.filter( ({ tick }) => tick <= atTick, ), checkpoints: [ ...this.recording.checkpoints.filter(({ tick }) => tick < atTick), branchCheckpoint, ], }, ); } private findCheckpoint( targetTick: number, ): ReplayCheckpoint { let selected = this.recording.checkpoints[0]; if (!selected) throw new Error("Replay has no initial checkpoint"); for (const checkpoint of this.recording.checkpoints) { if (checkpoint.tick <= targetTick && checkpoint.tick >= selected.tick) { selected = checkpoint; } } return selected; } private applyCommandsAtCurrentTick(): void { while (this.commandIndex < this.recording.commands.length) { const command = this.recording.commands[this.commandIndex]!; if (command.atTick > this.engine.tick) return; if (command.atTick < this.engine.tick) { throw new Error( `Replay command ${command.order} at tick ${command.atTick} is behind playback tick ${this.engine.tick}`, ); } switch (command.kind) { case "add-player": this.engine.addPlayer(command.playerId); break; case "remove-player": this.engine.removePlayer(command.playerId); break; case "input": { const decision = this.engine.submitInput(command.playerId, command.packet); if (!decision.accepted) { throw new Error( `Recorded input ${command.packet.sequence} was rejected during replay: ${decision.reason ?? "unknown"}`, ); } break; } } this.commandIndex += 1; } } private assertHash(tick: number, expected: StateHash): void { const actual = this.timeTravel.hashState(this.engine.currentState); if (!Object.is(expected, actual)) { throw new ReplayDivergenceError(tick, expected, actual); } } } export function withTimeTravel< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, definition: TimeTravelDefinition, ): TimeTravelNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash > { const timeTravel = normalizeDefinition(game.tickRateHz, definition); return Object.freeze({ ...game, createServer(options: TimeTravelServerOptions = {}) { const { replaySeed, recordingHistoryTicks: requestedRecordingHistoryTicks, ...serverOptions } = options; const recordingHistoryTicks = normalizeRecordingHistoryTicks( requestedRecordingHistoryTicks, ); const seed = replaySeed === undefined ? timeTravel.createSeed() : replaySeed; const engine = createSeededEngine( game, timeTravel, seed, serverOptions, ); return new TimeTravelAuthoritativeEngine( game, engine, timeTravel, seed, serverOptions, recordingHistoryTicks, ); }, createReplay( recording: ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash >, ) { return new ReplaySession(game, timeTravel, recording); }, }); } function normalizeDefinition( tickRateHz: number, definition: TimeTravelDefinition, ): NormalizedTimeTravelDefinition { const checkpointIntervalTicks = definition.checkpointIntervalTicks ?? tickRateHz * 2; const verificationIntervalTicks = definition.verificationIntervalTicks ?? 1; for (const [name, value] of [ ["checkpointIntervalTicks", checkpointIntervalTicks], ["verificationIntervalTicks", verificationIntervalTicks], ] as const) { if (!Number.isInteger(value) || value <= 0) { throw new RangeError(`${name} must be a positive integer`); } } return { ...definition, checkpointIntervalTicks, verificationIntervalTicks, }; } function normalizeRecordingHistoryTicks(value: number | undefined): number | null { if (value === undefined) return null; if (!Number.isInteger(value) || value <= 0) { throw new RangeError("recordingHistoryTicks must be a positive integer"); } return value; } function createSeededEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, timeTravel: NormalizedTimeTravelDefinition, seed: Seed, serverOptions: ServerEngineOptions, ): NetworkedAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent > { const definition: NetworkedGameDefinition< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent > = { tickRateHz: game.tickRateHz, snapshotRateHz: game.snapshotRateHz, server: { ...game.server, createInitialState() { const state = game.server.createInitialState(); timeTravel.initializeState?.(state, cloneSeed(timeTravel, seed)); return state; }, }, client: game.client, replication: game.replication, validateInput: game.validateInput, ...(game.inputStream ? { inputStream: game.inputStream } : {}), codecs: game.codecs, }; return new NetworkedAuthoritativeEngine(definition, serverOptions); } function cloneSeed( timeTravel: TimeTravelDefinition, seed: Seed, ): Seed { return timeTravel.cloneSeed ? timeTravel.cloneSeed(seed) : seed; } function cloneInputPacket< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, packet: InputPacket, ): InputPacket { const encoded = game.codecs.input.encode(packet.input); return { sequence: packet.sequence, targetTick: packet.targetTick, observedTick: packet.observedTick ?? packet.targetTick, input: game.codecs.input.decode(encoded), }; } function cloneCommand< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, command: ReplayCommand, ): ReplayCommand { return command.kind === "input" ? { ...command, packet: cloneInputPacket(game, command.packet) } : { ...command }; } function cloneSimulationCheckpoint< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, checkpoint: NetworkedSimulationCheckpoint< AuthorityState, Input, AuthorityEvent >, ): NetworkedSimulationCheckpoint { return { tick: checkpoint.tick, state: game.server.cloneState(checkpoint.state), playerIds: [...checkpoint.playerIds], lastReceivedSequences: checkpoint.lastReceivedSequences.map( ([playerId, sequence]) => [playerId, sequence] as const, ), acknowledgedSequences: checkpoint.acknowledgedSequences.map( ([playerId, sequence]) => [playerId, sequence] as const, ), queuedInputs: checkpoint.queuedInputs.map(({ playerId, packet }) => ({ playerId, packet: cloneInputPacket(game, packet), })), inputStreamStates: checkpoint.inputStreamStates.map((stream) => ({ ...stream, clientInput: game.codecs.input.decode( game.codecs.input.encode(stream.clientInput), ), appliedInput: game.codecs.input.decode( game.codecs.input.encode(stream.appliedInput), ), })), pendingEvents: [...checkpoint.pendingEvents], }; } function cloneReplayCheckpoint< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, StateHash extends ReplayStateHash, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, checkpoint: ReplayCheckpoint< AuthorityState, Input, AuthorityEvent, StateHash >, ): ReplayCheckpoint { return { tick: checkpoint.tick, commandCount: checkpoint.commandCount, hash: checkpoint.hash, simulation: cloneSimulationCheckpoint(game, checkpoint.simulation), authorityEvents: [...checkpoint.authorityEvents], }; } function cloneRecording< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, timeTravel: TimeTravelDefinition, recording: ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash >, ): ReplayRecording { return { version: 1, seed: cloneSeed(timeTravel, recording.seed), tickRateHz: recording.tickRateHz, snapshotRateHz: recording.snapshotRateHz, startTick: recording.startTick, durationTicks: recording.durationTicks, serverOptions: { ...recording.serverOptions }, commands: recording.commands.map((command) => cloneCommand(game, command)), verifications: recording.verifications.map((entry) => ({ ...entry })), checkpoints: recording.checkpoints.map((checkpoint) => cloneReplayCheckpoint(game, checkpoint), ), }; } function validateRecording< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, Seed, StateHash extends ReplayStateHash, >( game: DefinedNetworkedGame< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, recording: ReplayRecording< AuthorityState, Input, AuthorityEvent, Seed, StateHash >, ): void { if (recording.version !== 1) throw new RangeError("Unsupported replay version"); if ( recording.tickRateHz !== game.tickRateHz || recording.snapshotRateHz !== game.snapshotRateHz ) { throw new RangeError("Replay tick rates do not match this game"); } if (!Number.isInteger(recording.durationTicks) || recording.durationTicks < 0) { throw new RangeError("Replay duration must be a non-negative integer"); } if ( !Number.isInteger(recording.startTick) || recording.startTick < 0 || recording.startTick > recording.durationTicks ) { throw new RangeError("Replay start tick must be in the recorded range"); } if ( recording.checkpoints.length === 0 || recording.checkpoints[0]?.tick !== recording.startTick ) { throw new RangeError("Replay must begin with a checkpoint at startTick"); } let previousOrder = -1; let previousTick = recording.startTick; for (const command of recording.commands) { if ( !Number.isInteger(command.atTick) || command.atTick < previousTick || command.atTick < recording.startTick || command.atTick > recording.durationTicks || !Number.isInteger(command.order) || command.order <= previousOrder ) { throw new RangeError("Replay commands are not in canonical order"); } previousTick = command.atTick; previousOrder = command.order; } for (const checkpoint of recording.checkpoints) { if ( checkpoint.tick !== checkpoint.simulation.tick || checkpoint.tick < 0 || checkpoint.tick > recording.durationTicks || checkpoint.commandCount < 0 || checkpoint.commandCount > recording.commands.length ) { throw new RangeError("Replay contains an invalid checkpoint"); } } }