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