import { NetworkClock } from "./network-clock.js"; import type { ClientStateReport, GameDefinition, InputContext, InputPacket, PlayerId, StateSnapshot, } from "./types.js"; interface PredictedInput { packet: InputPacket; applied: boolean; } export class PredictedEngine { readonly game: GameDefinition; readonly networkClock = new NetworkClock(); private state: State; private playerId: PlayerId | null = null; private currentTick = 0; private nextInputSequence = 1; private acknowledgedSequence = 0; private pendingInputs: PredictedInput[] = []; constructor(game: GameDefinition) { this.game = game; this.state = game.createInitialState(); } get tick(): number { return this.currentTick; } get localPlayerId(): PlayerId | null { return this.playerId; } get currentState(): Readonly { return this.state; } get initialized(): boolean { return this.playerId !== null; } initialize(playerId: PlayerId, snapshot: StateSnapshot): void { this.playerId = playerId; this.currentTick = snapshot.tick; this.state = this.game.cloneState(snapshot.state); this.pendingInputs = []; this.acknowledgedSequence = 0; } createInput(input: Input, leadTicks?: number): InputPacket { if (this.playerId === null) { throw new Error("Client engine has not received a welcome snapshot"); } const targetTick = this.currentTick + (leadTicks ?? this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz)); const packet: InputPacket = { sequence: this.nextInputSequence, targetTick, observedTick: this.currentTick, input, }; const context = this.inputContext(packet); if (!this.game.validateInput(input, context)) { throw new Error("Refusing to send invalid local input"); } this.nextInputSequence = (this.nextInputSequence + 1) >>> 0; this.pendingInputs.push({ packet, applied: false }); return packet; } acknowledge(sequence: number): void { this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence); this.pendingInputs = this.pendingInputs.filter( (pending) => pending.packet.sequence > this.acknowledgedSequence, ); } reject(sequence: number): void { this.pendingInputs = this.pendingInputs.filter( (pending) => pending.packet.sequence !== sequence, ); } step(): void { if (this.playerId === null) { return; } this.currentTick += 1; this.applyDueInputs(); this.game.step(this.state, { tick: this.currentTick, deltaSeconds: 1 / this.game.tickRateHz, }); this.assertValidState(); } reconcile(snapshot: StateSnapshot): void { if ( this.playerId === null || snapshot.tick < this.currentTick - this.game.tickRateHz * 2 ) { return; } const predictedTick = Math.max(this.currentTick, snapshot.tick); this.state = this.game.cloneState(snapshot.state); this.currentTick = snapshot.tick; for (const pending of this.pendingInputs) { pending.applied = false; } this.applyDueInputs(); while (this.currentTick < predictedTick) { this.currentTick += 1; this.applyDueInputs(); this.game.step(this.state, { tick: this.currentTick, deltaSeconds: 1 / this.game.tickRateHz, }); } this.assertValidState(); } createStateReport(): ClientStateReport { return { tick: this.currentTick, state: this.game.cloneState(this.state), }; } private applyDueInputs(): void { for (const pending of this.pendingInputs) { if (!pending.applied && pending.packet.targetTick <= this.currentTick) { this.game.applyInput( this.state, pending.packet.input, this.inputContext(pending.packet), ); pending.applied = true; } } } private inputContext(packet: InputPacket): InputContext { if (this.playerId === null) { throw new Error("Client engine has not been initialized"); } return { playerId: this.playerId, sequence: packet.sequence, targetTick: packet.targetTick, observedTick: packet.observedTick ?? packet.targetTick, tick: this.currentTick, deltaSeconds: 1 / this.game.tickRateHz, }; } private assertValidState(): void { if ( this.game.validateState && !this.game.validateState(this.state, { tick: this.currentTick, deltaSeconds: 1 / this.game.tickRateHz, }) ) { throw new Error( `Client prediction produced invalid state at tick ${this.currentTick}`, ); } } }