import type { ClientStateContext, ClientStateReport, GameDefinition, InputContext, InputPacket, PlayerId, StateSnapshot, ValidationResult, } from "./types.js"; export interface ServerEngineOptions { historyTicks?: number; /** * Maximum transport delay accepted for an input. Late inputs are applied on * the next simulation step; they never rewrite authoritative history. */ maxPastTicks?: number; maxFutureTicks?: number; } export interface InputDecision { accepted: boolean; reason?: "unknown-player" | "duplicate" | "invalid" | "past" | "future"; } export interface Acknowledgement { playerId: PlayerId; sequence: number; } export interface ServerStepResult { tick: number; acknowledgements: Acknowledgement[]; validations: ValidationResult[]; snapshotDue: boolean; } interface QueuedInput { playerId: PlayerId; packet: InputPacket; } interface QueuedReport { playerId: PlayerId; report: ClientStateReport; } export class AuthoritativeEngine { readonly game: GameDefinition; private readonly historyTicks: number; private readonly maxPastTicks: number; private readonly maxFutureTicks: number; private readonly snapshotEveryTicks: number; private readonly players = new Set(); private readonly lastReceivedSequence = new Map(); private readonly acknowledgedSequence = new Map(); private readonly history = new Map(); private queuedInputs: QueuedInput[] = []; private queuedReports: QueuedReport[] = []; private state: State; private currentTick = 0; constructor( game: GameDefinition, options: ServerEngineOptions = {}, ) { this.game = game; this.historyTicks = options.historyTicks ?? game.tickRateHz * 2; this.maxPastTicks = options.maxPastTicks ?? game.tickRateHz; this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz; this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz; this.state = game.createInitialState(); this.storeHistory(); } get tick(): number { return this.currentTick; } get currentState(): Readonly { return this.state; } addPlayer(playerId: PlayerId): void { if (this.players.has(playerId)) { return; } this.players.add(playerId); this.game.addPlayer?.(this.state, { playerId, tick: this.currentTick }); this.assertValidState(); this.storeHistory(); } removePlayer(playerId: PlayerId): void { if (!this.players.delete(playerId)) { return; } this.game.removePlayer?.(this.state, { playerId, tick: this.currentTick, }); this.lastReceivedSequence.delete(playerId); this.acknowledgedSequence.delete(playerId); this.queuedInputs = this.queuedInputs.filter( (queued) => queued.playerId !== playerId, ); this.queuedReports = this.queuedReports.filter( (queued) => queued.playerId !== playerId, ); this.assertValidState(); this.storeHistory(); } submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { if (!this.players.has(playerId)) { return { accepted: false, reason: "unknown-player" }; } const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0; if (packet.sequence <= previousSequence) { return { accepted: false, reason: "duplicate" }; } if (packet.targetTick < this.currentTick - this.maxPastTicks) { return { accepted: false, reason: "past" }; } if (packet.targetTick > this.currentTick + this.maxFutureTicks) { return { accepted: false, reason: "future" }; } const context = this.inputContext(playerId, packet); if (!this.game.validateInput(packet.input, context)) { return { accepted: false, reason: "invalid" }; } this.lastReceivedSequence.set(playerId, packet.sequence); this.queuedInputs.push({ playerId, packet }); return { accepted: true }; } submitStateReport( playerId: PlayerId, report: ClientStateReport, ): ValidationResult | null { if (report.tick > this.currentTick + this.maxFutureTicks) { return { playerId, tick: report.tick, valid: false, reason: "too-far-ahead", }; } if (report.tick > this.currentTick) { this.queuedReports = this.queuedReports.filter( (queued) => queued.playerId !== playerId, ); this.queuedReports.push({ playerId, report }); return null; } return this.validateReport(playerId, report); } step(): ServerStepResult { this.currentTick += 1; const deltaSeconds = 1 / this.game.tickRateHz; const acknowledgementByPlayer = new Map(); const dueInputs: QueuedInput[] = []; const futureInputs: QueuedInput[] = []; for (const queued of this.queuedInputs) { (queued.packet.targetTick <= this.currentTick ? dueInputs : futureInputs ).push(queued); } this.queuedInputs = futureInputs; dueInputs.sort( (left, right) => left.packet.targetTick - right.packet.targetTick || left.playerId - right.playerId || left.packet.sequence - right.packet.sequence, ); for (const queued of dueInputs) { const { playerId, packet } = queued; this.game.applyInput( this.state, packet.input, this.inputContext(playerId, packet), ); const previousAck = this.acknowledgedSequence.get(playerId) ?? 0; if (packet.sequence > previousAck) { this.acknowledgedSequence.set(playerId, packet.sequence); acknowledgementByPlayer.set(playerId, packet.sequence); } } this.game.step(this.state, { tick: this.currentTick, deltaSeconds, }); this.assertValidState(); this.storeHistory(); const validations: ValidationResult[] = []; const futureReports: QueuedReport[] = []; for (const queued of this.queuedReports) { if (queued.report.tick <= this.currentTick) { validations.push( this.validateReport(queued.playerId, queued.report), ); } else { futureReports.push(queued); } } this.queuedReports = futureReports; return { tick: this.currentTick, acknowledgements: [...acknowledgementByPlayer].map( ([playerId, sequence]) => ({ playerId, sequence }), ), validations, snapshotDue: this.currentTick % this.snapshotEveryTicks === 0, }; } createSnapshot(serverTime: number): StateSnapshot { return { tick: this.currentTick, serverTime, state: this.game.cloneState(this.state), }; } private inputContext( playerId: PlayerId, packet: InputPacket, ): InputContext { return { playerId, sequence: packet.sequence, targetTick: packet.targetTick, observedTick: packet.observedTick ?? packet.targetTick, tick: this.currentTick, deltaSeconds: 1 / this.game.tickRateHz, }; } private validateReport( playerId: PlayerId, report: ClientStateReport, ): ValidationResult { const authoritative = this.history.get(report.tick); if (!authoritative) { return { playerId, tick: report.tick, valid: false, reason: "outside-history", }; } const context: ClientStateContext = { playerId, tick: report.tick }; const valid = this.game.validateClientState ? this.game.validateClientState(authoritative, report.state, context) : (this.game.validateState?.(report.state, { tick: report.tick, deltaSeconds: 1 / this.game.tickRateHz, }) ?? true); return { playerId, tick: report.tick, valid, ...(valid ? {} : { reason: "mismatch" as const }), }; } private assertValidState(): void { if ( this.game.validateState && !this.game.validateState(this.state, { tick: this.currentTick, deltaSeconds: 1 / this.game.tickRateHz, }) ) { throw new Error(`Game produced invalid state at tick ${this.currentTick}`); } } private storeHistory(): void { this.history.set(this.currentTick, this.game.cloneState(this.state)); const oldestTick = this.currentTick - this.historyTicks; for (const tick of this.history.keys()) { if (tick < oldestTick) { this.history.delete(tick); } } } }