import type { ClientStateReport, InputContext, InputPacket, PlayerId, StateSnapshot, ValidationResult, } from "./types.js"; import type { NetworkedGameDefinition, SnapshotBatch, } from "./networked-types.js"; import type { Acknowledgement, InputDecision, ServerEngineOptions, } from "./server.js"; export interface NetworkedServerStepResult { tick: number; acknowledgements: Acknowledgement[]; validations: ValidationResult[]; events: AuthorityEvent[]; snapshotDue: boolean; } export interface NetworkedSimulationCheckpoint< AuthorityState, Input, AuthorityEvent, > { tick: number; state: AuthorityState; playerIds: PlayerId[]; lastReceivedSequences: Array; acknowledgedSequences: Array; queuedInputs: Array<{ playerId: PlayerId; packet: InputPacket; }>; pendingEvents: AuthorityEvent[]; } interface QueuedInput { playerId: PlayerId; packet: InputPacket; } interface QueuedReport { playerId: PlayerId; report: ClientStateReport; } export class NetworkedAuthoritativeEngine< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent, > { readonly game: NetworkedGameDefinition< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >; 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 pendingEvents: AuthorityEvent[] = []; private state: AuthorityState; private currentTick = 0; constructor( game: NetworkedGameDefinition< AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent >, options: ServerEngineOptions = {}, ) { this.game = game; this.historyTicks = options.historyTicks ?? game.tickRateHz * 2; this.maxPastTicks = options.maxPastTicks ?? 2; this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz; this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz; this.state = game.server.createInitialState(); game.server.initializeState?.(this.state, { tick: 0, deltaSeconds: 1 / game.tickRateHz, }); this.assertValidState(); this.storeHistory(); } get tick(): number { return this.currentTick; } get currentState(): Readonly { return this.state; } get playerIds(): PlayerId[] { return [...this.players]; } addPlayer(playerId: PlayerId): void { if (this.players.has(playerId)) { return; } this.players.add(playerId); this.game.server.addPlayer?.(this.state, { playerId, tick: this.currentTick, emit: (event) => this.pendingEvents.push(event), }); this.assertValidState(); this.storeHistory(); } removePlayer(playerId: PlayerId): void { if (!this.players.delete(playerId)) { return; } this.game.server.removePlayer?.(this.state, { playerId, tick: this.currentTick, emit: (event) => this.pendingEvents.push(event), }); 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(): NetworkedServerStepResult { this.currentTick += 1; const deltaSeconds = 1 / this.game.tickRateHz; const events = this.pendingEvents; this.pendingEvents = []; const emit = (event: AuthorityEvent) => events.push(event); 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.server.applyInput(this.state, packet.input, { ...this.inputContext(playerId, packet), emit, }); const previousAck = this.acknowledgedSequence.get(playerId) ?? 0; if (packet.sequence > previousAck) { this.acknowledgedSequence.set(playerId, packet.sequence); acknowledgementByPlayer.set(playerId, packet.sequence); } } this.game.server.step(this.state, { tick: this.currentTick, deltaSeconds, emit, }); 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, events, snapshotDue: this.currentTick % this.snapshotEveryTicks === 0, }; } createSnapshot( playerId: PlayerId, serverTime: number, ): StateSnapshot { return { tick: this.currentTick, serverTime, state: this.game.replication.createSnapshot(this.state, { playerId, tick: this.currentTick, }), }; } createSnapshotBatches(serverTime: number): SnapshotBatch[] { const groups = new Map(); for (const playerId of this.players) { const context = { playerId, tick: this.currentTick }; const key = this.game.replication.groupKey?.(this.state, context) ?? playerId; const group = groups.get(key); if (group) { group.push(playerId); } else { groups.set(key, [playerId]); } } return [...groups.values()].map((playerIds) => ({ playerIds, state: this.createSnapshot(playerIds[0]!, serverTime).state, })); } createPerceptions( playerId: PlayerId, events: readonly AuthorityEvent[], ): PerceptionEvent[] { if (!this.game.replication.perceive) { return []; } const perceptions: PerceptionEvent[] = []; for (const event of events) { const perception = this.game.replication.perceive( this.state, event, { playerId, tick: this.currentTick }, ); if (perception !== null) { perceptions.push(perception); } } return perceptions; } createSimulationCheckpoint(): NetworkedSimulationCheckpoint< AuthorityState, Input, AuthorityEvent > { return { tick: this.currentTick, state: this.game.server.cloneState(this.state), playerIds: [...this.players], lastReceivedSequences: [...this.lastReceivedSequence], acknowledgedSequences: [...this.acknowledgedSequence], queuedInputs: this.queuedInputs.map(({ playerId, packet }) => ({ playerId, packet: this.cloneInputPacket(packet), })), pendingEvents: [...this.pendingEvents], }; } restoreSimulationCheckpoint( checkpoint: NetworkedSimulationCheckpoint< AuthorityState, Input, AuthorityEvent >, ): void { if (!Number.isInteger(checkpoint.tick) || checkpoint.tick < 0) { throw new RangeError("checkpoint tick must be a non-negative integer"); } this.currentTick = checkpoint.tick; this.state = this.game.server.cloneState(checkpoint.state); this.players.clear(); for (const playerId of checkpoint.playerIds) this.players.add(playerId); this.lastReceivedSequence.clear(); for (const [playerId, sequence] of checkpoint.lastReceivedSequences) { this.lastReceivedSequence.set(playerId, sequence); } this.acknowledgedSequence.clear(); for (const [playerId, sequence] of checkpoint.acknowledgedSequences) { this.acknowledgedSequence.set(playerId, sequence); } this.queuedInputs = checkpoint.queuedInputs.map(({ playerId, packet }) => ({ playerId, packet: this.cloneInputPacket(packet), })); this.queuedReports = []; this.pendingEvents = [...checkpoint.pendingEvents]; this.history.clear(); this.assertValidState(); this.storeHistory(); } 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 cloneInputPacket(packet: InputPacket): InputPacket { const encoded = this.game.codecs.input.encode(packet.input); return { sequence: packet.sequence, targetTick: packet.targetTick, observedTick: packet.observedTick ?? packet.targetTick, input: this.game.codecs.input.decode(encoded), }; } 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 valid = this.game.replication.validateClientState ? this.game.replication.validateClientState( authoritative, report.state, { playerId, tick: report.tick }, ) : (this.game.client.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.server.validateState && !this.game.server.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.server.cloneStateForHistory?.(this.state) ?? this.game.server.cloneState(this.state), ); const oldestTick = this.currentTick - this.historyTicks; for (const tick of this.history.keys()) { if (tick < oldestTick) { this.history.delete(tick); } } } }