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");
}
}
}