This commit is contained in:
185
packages/engine/src/client.ts
Normal file
185
packages/engine/src/client.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NetworkClock } from "./network-clock.js";
|
||||
import type {
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
interface PredictedInput<Input> {
|
||||
packet: InputPacket<Input>;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export class PredictedEngine<State, Input> {
|
||||
readonly game: GameDefinition<State, Input>;
|
||||
readonly networkClock = new NetworkClock();
|
||||
private state: State;
|
||||
private playerId: PlayerId | null = null;
|
||||
private currentTick = 0;
|
||||
private nextInputSequence = 1;
|
||||
private acknowledgedSequence = 0;
|
||||
private pendingInputs: PredictedInput<Input>[] = [];
|
||||
|
||||
constructor(game: GameDefinition<State, Input>) {
|
||||
this.game = game;
|
||||
this.state = game.createInitialState();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get localPlayerId(): PlayerId | null {
|
||||
return this.playerId;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<State> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
get initialized(): boolean {
|
||||
return this.playerId !== null;
|
||||
}
|
||||
|
||||
initialize(playerId: PlayerId, snapshot: StateSnapshot<State>): 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<Input> {
|
||||
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<Input> = {
|
||||
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<State>): 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<State> {
|
||||
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<Input>): 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/engine/src/clock.ts
Normal file
55
packages/engine/src/clock.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface FixedStepClockOptions {
|
||||
rateHz: number;
|
||||
maxCatchUpSteps?: number;
|
||||
}
|
||||
|
||||
export class FixedStepClock {
|
||||
readonly stepMilliseconds: number;
|
||||
private readonly maxCatchUpSteps: number;
|
||||
private previousTime: number | null = null;
|
||||
private accumulator = 0;
|
||||
|
||||
constructor(options: FixedStepClockOptions) {
|
||||
if (!Number.isFinite(options.rateHz) || options.rateHz <= 0) {
|
||||
throw new RangeError("Clock rate must be a positive finite number");
|
||||
}
|
||||
|
||||
this.stepMilliseconds = 1_000 / options.rateHz;
|
||||
this.maxCatchUpSteps = options.maxCatchUpSteps ?? 5;
|
||||
}
|
||||
|
||||
advance(now: number, step: () => void): number {
|
||||
if (this.previousTime === null) {
|
||||
this.previousTime = now;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, now - this.previousTime);
|
||||
this.previousTime = now;
|
||||
this.accumulator += Math.min(
|
||||
elapsed,
|
||||
this.stepMilliseconds * this.maxCatchUpSteps,
|
||||
);
|
||||
|
||||
let steps = 0;
|
||||
while (
|
||||
this.accumulator >= this.stepMilliseconds &&
|
||||
steps < this.maxCatchUpSteps
|
||||
) {
|
||||
step();
|
||||
this.accumulator -= this.stepMilliseconds;
|
||||
steps += 1;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
get interpolationAlpha(): number {
|
||||
return this.accumulator / this.stepMilliseconds;
|
||||
}
|
||||
|
||||
reset(now?: number): void {
|
||||
this.previousTime = now ?? null;
|
||||
this.accumulator = 0;
|
||||
}
|
||||
}
|
||||
38
packages/engine/src/define-game.ts
Normal file
38
packages/engine/src/define-game.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PredictedEngine } from "./client.js";
|
||||
import { AuthoritativeEngine, type ServerEngineOptions } from "./server.js";
|
||||
import type { GameDefinition } from "./types.js";
|
||||
|
||||
export interface DefinedGame<State, Input>
|
||||
extends GameDefinition<State, Input> {
|
||||
createServer(options?: ServerEngineOptions): AuthoritativeEngine<State, Input>;
|
||||
createClient(): PredictedEngine<State, Input>;
|
||||
}
|
||||
|
||||
export function defineGame<State, Input>(
|
||||
definition: GameDefinition<State, Input>,
|
||||
): DefinedGame<State, Input> {
|
||||
if (!Number.isInteger(definition.tickRateHz) || definition.tickRateHz <= 0) {
|
||||
throw new RangeError("tickRateHz must be a positive integer");
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(definition.snapshotRateHz) ||
|
||||
definition.snapshotRateHz <= 0 ||
|
||||
definition.snapshotRateHz > definition.tickRateHz ||
|
||||
definition.tickRateHz % definition.snapshotRateHz !== 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"snapshotRateHz must be a positive divisor of tickRateHz",
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
createServer(options?: ServerEngineOptions) {
|
||||
return new AuthoritativeEngine(definition, options);
|
||||
},
|
||||
createClient() {
|
||||
return new PredictedEngine(definition);
|
||||
},
|
||||
});
|
||||
}
|
||||
90
packages/engine/src/define-multiplayer-game.ts
Normal file
90
packages/engine/src/define-multiplayer-game.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type {
|
||||
ClientSimulationRules,
|
||||
ReplicationRules,
|
||||
ServerSimulationRules,
|
||||
} from "./networked-types.js";
|
||||
import type { BinaryCodec, InputContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Names the five types that cross a multiplayer game's trust boundaries.
|
||||
* Define this once, then every callback in defineMultiplayerGame is inferred.
|
||||
*/
|
||||
export interface MultiplayerGameContract {
|
||||
authority: unknown;
|
||||
client: unknown;
|
||||
input: unknown;
|
||||
authorityEvent: unknown;
|
||||
perceptionEvent: unknown;
|
||||
}
|
||||
|
||||
export interface MultiplayerGameConfiguration<
|
||||
Contract extends MultiplayerGameContract,
|
||||
> {
|
||||
clock: {
|
||||
ticksPerSecond: number;
|
||||
snapshotsPerSecond: number;
|
||||
};
|
||||
/** Private rules that run only on trusted authority. */
|
||||
authority: ServerSimulationRules<
|
||||
Contract["authority"],
|
||||
Contract["input"],
|
||||
Contract["authorityEvent"]
|
||||
>;
|
||||
/** Rules the client may run speculatively between snapshots. */
|
||||
prediction: ClientSimulationRules<
|
||||
Contract["client"],
|
||||
Contract["input"],
|
||||
Contract["perceptionEvent"]
|
||||
>;
|
||||
/** The only authority data and events a particular player may receive. */
|
||||
visibility: ReplicationRules<
|
||||
Contract["authority"],
|
||||
Contract["client"],
|
||||
Contract["authorityEvent"],
|
||||
Contract["perceptionEvent"]
|
||||
>;
|
||||
input: {
|
||||
validate(input: Contract["input"], context: InputContext): boolean;
|
||||
};
|
||||
encoding: {
|
||||
input: BinaryCodec<Contract["input"]>;
|
||||
clientState: BinaryCodec<Contract["client"]>;
|
||||
perception?: BinaryCodec<Contract["perceptionEvent"]>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer-facing game-definition API. It compiles the readable game sections
|
||||
* into the same low-level definition consumed by every engine HOF.
|
||||
*/
|
||||
export function defineMultiplayerGame<
|
||||
Contract extends MultiplayerGameContract,
|
||||
>(
|
||||
configuration: MultiplayerGameConfiguration<Contract>,
|
||||
): DefinedNetworkedGame<
|
||||
Contract["authority"],
|
||||
Contract["client"],
|
||||
Contract["input"],
|
||||
Contract["authorityEvent"],
|
||||
Contract["perceptionEvent"]
|
||||
> {
|
||||
return defineNetworkedGame({
|
||||
tickRateHz: configuration.clock.ticksPerSecond,
|
||||
snapshotRateHz: configuration.clock.snapshotsPerSecond,
|
||||
server: configuration.authority,
|
||||
client: configuration.prediction,
|
||||
replication: configuration.visibility,
|
||||
validateInput: configuration.input.validate,
|
||||
codecs: {
|
||||
input: configuration.encoding.input,
|
||||
state: configuration.encoding.clientState,
|
||||
...(configuration.encoding.perception
|
||||
? { event: configuration.encoding.perception }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
93
packages/engine/src/define-networked-game.ts
Normal file
93
packages/engine/src/define-networked-game.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NetworkedPredictedEngine } from "./networked-client.js";
|
||||
import { NetworkedAuthoritativeEngine } from "./networked-server.js";
|
||||
import type { NetworkedGameDefinition } from "./networked-types.js";
|
||||
import { createBinaryProtocol, type BinaryProtocol } from "./protocol.js";
|
||||
import type { ServerEngineOptions } from "./server.js";
|
||||
|
||||
export interface DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent = never,
|
||||
PerceptionEvent = never,
|
||||
> extends NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
protocol: BinaryProtocol<Input, ClientState, PerceptionEvent>;
|
||||
createServer(
|
||||
options?: ServerEngineOptions,
|
||||
): NetworkedAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
createClient(): NetworkedPredictedEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
}
|
||||
|
||||
export function defineNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent = never,
|
||||
PerceptionEvent = never,
|
||||
>(
|
||||
definition: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
validateRates(definition.tickRateHz, definition.snapshotRateHz);
|
||||
|
||||
const protocol = createBinaryProtocol<Input, ClientState, PerceptionEvent>(
|
||||
definition.codecs,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
protocol,
|
||||
createServer(options?: ServerEngineOptions) {
|
||||
return new NetworkedAuthoritativeEngine(definition, options);
|
||||
},
|
||||
createClient() {
|
||||
return new NetworkedPredictedEngine(definition);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function validateRates(tickRateHz: number, snapshotRateHz: number): void {
|
||||
if (!Number.isInteger(tickRateHz) || tickRateHz <= 0) {
|
||||
throw new RangeError("tickRateHz must be a positive integer");
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(snapshotRateHz) ||
|
||||
snapshotRateHz <= 0 ||
|
||||
snapshotRateHz > tickRateHz ||
|
||||
tickRateHz % snapshotRateHz !== 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"snapshotRateHz must be a positive divisor of tickRateHz",
|
||||
);
|
||||
}
|
||||
}
|
||||
113
packages/engine/src/deterministic-hash.ts
Normal file
113
packages/engine/src/deterministic-hash.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
const fnvOffset = 0xcbf29ce484222325n;
|
||||
const fnvPrime = 0x100000001b3n;
|
||||
const uint64Mask = 0xffffffffffffffffn;
|
||||
|
||||
/**
|
||||
* Produces a stable 64-bit hash for deterministic simulation state.
|
||||
* Maps, sets, and object keys are sorted so insertion order does not affect it.
|
||||
* Cyclic structures and executable values are rejected deliberately.
|
||||
*/
|
||||
export function deterministicHash(value: unknown): string {
|
||||
const canonical = canonicalize(value, new WeakSet<object>());
|
||||
let hash = fnvOffset;
|
||||
|
||||
for (let index = 0; index < canonical.length; index += 1) {
|
||||
const code = canonical.charCodeAt(index);
|
||||
hash ^= BigInt(code & 0xff);
|
||||
hash = (hash * fnvPrime) & uint64Mask;
|
||||
hash ^= BigInt(code >>> 8);
|
||||
hash = (hash * fnvPrime) & uint64Mask;
|
||||
}
|
||||
|
||||
return hash.toString(16).padStart(16, "0");
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown, ancestors: WeakSet<object>): string {
|
||||
if (value === null) return "null";
|
||||
|
||||
switch (typeof value) {
|
||||
case "undefined":
|
||||
return "undefined";
|
||||
case "boolean":
|
||||
return value ? "boolean:true" : "boolean:false";
|
||||
case "number":
|
||||
if (Number.isNaN(value)) return "number:NaN";
|
||||
if (value === Infinity) return "number:+Infinity";
|
||||
if (value === -Infinity) return "number:-Infinity";
|
||||
if (Object.is(value, -0)) return "number:-0";
|
||||
return `number:${value}`;
|
||||
case "bigint":
|
||||
return `bigint:${value}`;
|
||||
case "string":
|
||||
return `string:${JSON.stringify(value)}`;
|
||||
case "symbol":
|
||||
case "function":
|
||||
throw new TypeError(`Cannot hash simulation value of type ${typeof value}`);
|
||||
case "object":
|
||||
return canonicalizeObject(value, ancestors);
|
||||
}
|
||||
|
||||
throw new TypeError("Unsupported simulation value");
|
||||
}
|
||||
|
||||
function canonicalizeObject(value: object, ancestors: WeakSet<object>): string {
|
||||
if (ancestors.has(value)) {
|
||||
throw new TypeError("Cannot hash cyclic simulation state");
|
||||
}
|
||||
ancestors.add(value);
|
||||
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `array:[${value.map((entry) => canonicalize(entry, ancestors)).join(",")}]`;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return `date:${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
const entries = [...value].map(([key, entryValue]) => [
|
||||
canonicalize(key, ancestors),
|
||||
canonicalize(entryValue, ancestors),
|
||||
] as const);
|
||||
entries.sort(([left], [right]) => compareText(left, right));
|
||||
return `map:{${entries
|
||||
.map(([key, entryValue]) => `${key}=>${entryValue}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
const entries = [...value].map((entry) => canonicalize(entry, ancestors));
|
||||
entries.sort(compareText);
|
||||
return `set:{${entries.join(",")}}`;
|
||||
}
|
||||
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return `bytes:${bytesToHex(new Uint8Array(value))}`;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return `${value.constructor.name}:${bytesToHex(
|
||||
new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
|
||||
)}`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort(compareText);
|
||||
return `object:{${keys
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key], ancestors)}`)
|
||||
.join(",")}}`;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let result = "";
|
||||
for (const byte of bytes) result += byte.toString(16).padStart(2, "0");
|
||||
return result;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
113
packages/engine/src/index.ts
Normal file
113
packages/engine/src/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export { FixedStepClock, type FixedStepClockOptions } from "./clock.js";
|
||||
export { deterministicHash } from "./deterministic-hash.js";
|
||||
export { PredictedEngine } from "./client.js";
|
||||
export {
|
||||
defineGame,
|
||||
type DefinedGame,
|
||||
} from "./define-game.js";
|
||||
export {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
export {
|
||||
defineMultiplayerGame,
|
||||
type MultiplayerGameConfiguration,
|
||||
type MultiplayerGameContract,
|
||||
} from "./define-multiplayer-game.js";
|
||||
export {
|
||||
createJsonCodec,
|
||||
type JsonCodecOptions,
|
||||
} from "./json-codec.js";
|
||||
export {
|
||||
withLagCompensation,
|
||||
type LagCompensatedAction,
|
||||
type LagCompensationActionPolicy,
|
||||
type LagCompensationDefinition,
|
||||
type LagCompensationMode,
|
||||
type LagCompensationPolicies,
|
||||
type LagCompensationResolutionContext,
|
||||
} from "./lag-compensation.js";
|
||||
export { NetworkedPredictedEngine } from "./networked-client.js";
|
||||
export {
|
||||
NetworkedAuthoritativeEngine,
|
||||
type NetworkedServerStepResult,
|
||||
type NetworkedSimulationCheckpoint,
|
||||
} from "./networked-server.js";
|
||||
export type {
|
||||
ClientEventContext,
|
||||
ClientSimulationRules,
|
||||
EmittingInputContext,
|
||||
EmittingPlayerContext,
|
||||
EmittingTickContext,
|
||||
NetworkedGameDefinition,
|
||||
ReplicationRules,
|
||||
ServerSimulationRules,
|
||||
SnapshotBatch,
|
||||
} from "./networked-types.js";
|
||||
export { NetworkClock, type NetworkStats } from "./network-clock.js";
|
||||
export {
|
||||
createBinaryProtocol,
|
||||
type BinaryProtocol,
|
||||
type ClientWireMessage,
|
||||
type ServerWireMessage,
|
||||
} from "./protocol.js";
|
||||
export {
|
||||
ReplayTransportAuthoritativeEngine,
|
||||
withReplayTransport,
|
||||
type ProjectedReplayFrame,
|
||||
type ReplayAuthorizationContext,
|
||||
type ReplayTicket,
|
||||
type ReplayTicketPlan,
|
||||
type ReplayTransportDefinition,
|
||||
type ReplayTransportNetworkedGame,
|
||||
type ReplayTriggerContext,
|
||||
} from "./replay-transport.js";
|
||||
export {
|
||||
AuthoritativeEngine,
|
||||
type Acknowledgement,
|
||||
type InputDecision,
|
||||
type ServerEngineOptions,
|
||||
type ServerStepResult,
|
||||
} from "./server.js";
|
||||
export {
|
||||
SpatialGridIndex,
|
||||
withSpatialReplication,
|
||||
type SpatialPoint,
|
||||
type SpatialReplicationController,
|
||||
type SpatialReplicationDefinition,
|
||||
type SpatialReplicationEntity,
|
||||
type SpatialReplicationSelection,
|
||||
type SpatialReplicationSource,
|
||||
type SpatialReplicationViewerContext,
|
||||
type SpatiallyReplicatedNetworkedGame,
|
||||
} from "./spatial-replication.js";
|
||||
export {
|
||||
ReplayDivergenceError,
|
||||
ReplaySession,
|
||||
TimeTravelAuthoritativeEngine,
|
||||
withTimeTravel,
|
||||
type ReplayCheckpoint,
|
||||
type ReplayCommand,
|
||||
type ReplayFrame,
|
||||
type ReplayRecording,
|
||||
type ReplayStateHash,
|
||||
type ReplayVerification,
|
||||
type TimeTravelDefinition,
|
||||
type TimeTravelNetworkedGame,
|
||||
type TimeTravelServerOptions,
|
||||
} from "./time-travel.js";
|
||||
export type {
|
||||
BinaryCodec,
|
||||
ClientStateContext,
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PingPacket,
|
||||
PlayerContext,
|
||||
PlayerId,
|
||||
PongPacket,
|
||||
StateSnapshot,
|
||||
TickContext,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
45
packages/engine/src/json-codec.ts
Normal file
45
packages/engine/src/json-codec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { BinaryCodec } from "./types.js";
|
||||
|
||||
export interface JsonCodecOptions<Value> {
|
||||
serialize?(value: Value): unknown;
|
||||
deserialize?(value: unknown): Value;
|
||||
}
|
||||
|
||||
interface Utf8Encoder {
|
||||
encode(value: string): Uint8Array;
|
||||
}
|
||||
|
||||
interface Utf8Decoder {
|
||||
decode(value: Uint8Array): string;
|
||||
}
|
||||
|
||||
type Utf8Globals = typeof globalThis & {
|
||||
TextEncoder: new () => Utf8Encoder;
|
||||
TextDecoder: new (
|
||||
label?: string,
|
||||
options?: { fatal?: boolean },
|
||||
) => Utf8Decoder;
|
||||
};
|
||||
|
||||
/** Creates a UTF-8 JSON codec for prototypes and low-volume game messages. */
|
||||
export function createJsonCodec<Value>(
|
||||
options: JsonCodecOptions<Value> = {},
|
||||
): BinaryCodec<Value> {
|
||||
const utf8 = globalThis as Utf8Globals;
|
||||
const encoder = new utf8.TextEncoder();
|
||||
const decoder = new utf8.TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
return {
|
||||
encode(value) {
|
||||
const json = JSON.stringify(options.serialize?.(value) ?? value);
|
||||
if (json === undefined) {
|
||||
throw new TypeError("The JSON codec cannot encode undefined");
|
||||
}
|
||||
return encoder.encode(json);
|
||||
},
|
||||
decode(payload) {
|
||||
const value: unknown = JSON.parse(decoder.decode(payload));
|
||||
return options.deserialize ? options.deserialize(value) : (value as Value);
|
||||
},
|
||||
};
|
||||
}
|
||||
425
packages/engine/src/lag-compensation.ts
Normal file
425
packages/engine/src/lag-compensation.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type { EmittingInputContext } from "./networked-types.js";
|
||||
import type { PlayerId, TickContext } from "./types.js";
|
||||
|
||||
export type LagCompensationMode = "current" | "rewind" | "fast-forward";
|
||||
|
||||
export interface LagCompensatedAction {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent,
|
||||
> {
|
||||
currentState: AuthorityState;
|
||||
historicalState: Readonly<HistoricalState>;
|
||||
action: Readonly<Action>;
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
requestedTick: number;
|
||||
resolvedTick: number;
|
||||
currentTick: number;
|
||||
rewindTicks: number;
|
||||
catchUpTicks: number;
|
||||
clamped: boolean;
|
||||
emit(event: AuthorityEvent): void;
|
||||
}
|
||||
|
||||
export interface LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent,
|
||||
> {
|
||||
mode: LagCompensationMode;
|
||||
maximumRewindMs?: number;
|
||||
maximumFutureMs?: number;
|
||||
outOfWindow?: "clamp" | "reject";
|
||||
validate?(
|
||||
context: LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>,
|
||||
): boolean;
|
||||
resolve(
|
||||
context: LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>,
|
||||
): void;
|
||||
}
|
||||
|
||||
export type LagCompensationPolicies<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
AuthorityEvent,
|
||||
> = {
|
||||
[Kind in Action["type"]]: LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Extract<Action, { type: Kind }>,
|
||||
AuthorityEvent
|
||||
>;
|
||||
};
|
||||
|
||||
export interface LagCompensationDefinition<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
> {
|
||||
historySeconds: number;
|
||||
captureState(
|
||||
state: Readonly<AuthorityState>,
|
||||
context: TickContext,
|
||||
): HistoricalState;
|
||||
cloneHistoricalState(state: Readonly<HistoricalState>): HistoricalState;
|
||||
classifyAction(
|
||||
input: Readonly<Input>,
|
||||
context: EmittingInputContext<AuthorityEvent>,
|
||||
): Action | null;
|
||||
cloneAction(action: Readonly<Action>): Action;
|
||||
actions: LagCompensationPolicies<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>;
|
||||
}
|
||||
|
||||
interface HistoricalFrame<HistoricalState> {
|
||||
tick: number;
|
||||
state: HistoricalState;
|
||||
}
|
||||
|
||||
interface QueuedAction<Action> {
|
||||
action: Action;
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
requestedTick: number;
|
||||
}
|
||||
|
||||
interface LagRuntime<HistoricalState, Action> {
|
||||
frames: Array<HistoricalFrame<HistoricalState>>;
|
||||
queuedActions: Array<QueuedAction<Action>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one private authority-history ring to a game and resolves only classified
|
||||
* actions against it. The live world is never rewound or exposed to clients.
|
||||
*/
|
||||
export function withLagCompensation<
|
||||
AuthorityState extends object,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
>(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
definition: LagCompensationDefinition<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action
|
||||
>,
|
||||
): DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
const historyTicks = normalizeHistoryTicks(
|
||||
game.tickRateHz,
|
||||
definition.historySeconds,
|
||||
);
|
||||
validatePolicies(definition.actions);
|
||||
const runtimes = new WeakMap<
|
||||
AuthorityState,
|
||||
LagRuntime<HistoricalState, Action>
|
||||
>();
|
||||
|
||||
const ensureRuntime = (
|
||||
state: AuthorityState,
|
||||
): LagRuntime<HistoricalState, Action> => {
|
||||
const existing = runtimes.get(state);
|
||||
if (existing) return existing;
|
||||
const created = { frames: [], queuedActions: [] };
|
||||
runtimes.set(state, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const capture = (state: AuthorityState, context: TickContext): void => {
|
||||
const runtime = ensureRuntime(state);
|
||||
const frame = {
|
||||
tick: context.tick,
|
||||
state: definition.cloneHistoricalState(
|
||||
definition.captureState(state, context),
|
||||
),
|
||||
};
|
||||
if (runtime.frames.at(-1)?.tick === context.tick) {
|
||||
runtime.frames[runtime.frames.length - 1] = frame;
|
||||
} else {
|
||||
runtime.frames.push(frame);
|
||||
}
|
||||
const earliestTick = context.tick - historyTicks;
|
||||
while (runtime.frames[0] && runtime.frames[0].tick < earliestTick) {
|
||||
runtime.frames.shift();
|
||||
}
|
||||
};
|
||||
|
||||
return defineNetworkedGame({
|
||||
...game,
|
||||
server: {
|
||||
...game.server,
|
||||
createInitialState() {
|
||||
const state = game.server.createInitialState();
|
||||
ensureRuntime(state);
|
||||
return state;
|
||||
},
|
||||
initializeState(state, context) {
|
||||
game.server.initializeState?.(state, context);
|
||||
capture(state, context);
|
||||
},
|
||||
cloneState(state) {
|
||||
const cloned = game.server.cloneState(state);
|
||||
const source = ensureRuntime(state);
|
||||
runtimes.set(cloned, {
|
||||
frames: source.frames.map((frame) => ({
|
||||
tick: frame.tick,
|
||||
state: definition.cloneHistoricalState(frame.state),
|
||||
})),
|
||||
queuedActions: source.queuedActions.map((queued) => ({
|
||||
...queued,
|
||||
action: definition.cloneAction(queued.action),
|
||||
})),
|
||||
});
|
||||
return cloned;
|
||||
},
|
||||
cloneStateForHistory(state) {
|
||||
return game.server.cloneStateForHistory?.(state) ??
|
||||
game.server.cloneState(state);
|
||||
},
|
||||
addPlayer(state, context) {
|
||||
game.server.addPlayer?.(state, context);
|
||||
capture(state, {
|
||||
tick: context.tick,
|
||||
deltaSeconds: 1 / game.tickRateHz,
|
||||
});
|
||||
},
|
||||
removePlayer(state, context) {
|
||||
game.server.removePlayer?.(state, context);
|
||||
capture(state, {
|
||||
tick: context.tick,
|
||||
deltaSeconds: 1 / game.tickRateHz,
|
||||
});
|
||||
},
|
||||
applyInput(state, input, context) {
|
||||
game.server.applyInput(state, input, context);
|
||||
const action = definition.classifyAction(input, context);
|
||||
if (!action) return;
|
||||
const policy = definition.actions[action.type as Action["type"]] as
|
||||
| LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>
|
||||
| undefined;
|
||||
if (!policy) return;
|
||||
ensureRuntime(state).queuedActions.push({
|
||||
action: definition.cloneAction(action),
|
||||
playerId: context.playerId,
|
||||
sequence: context.sequence,
|
||||
requestedTick: context.observedTick,
|
||||
});
|
||||
},
|
||||
step(state, context) {
|
||||
game.server.step(state, context);
|
||||
const runtime = ensureRuntime(state);
|
||||
const queuedActions = runtime.queuedActions;
|
||||
runtime.queuedActions = [];
|
||||
|
||||
for (const queued of queuedActions) {
|
||||
resolveQueuedAction(
|
||||
state,
|
||||
context,
|
||||
runtime.frames,
|
||||
queued,
|
||||
game.tickRateHz,
|
||||
historyTicks,
|
||||
definition,
|
||||
);
|
||||
}
|
||||
capture(state, context);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveQueuedAction<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action extends LagCompensatedAction,
|
||||
>(
|
||||
state: AuthorityState,
|
||||
tickContext: TickContext & { emit(event: AuthorityEvent): void },
|
||||
frames: readonly HistoricalFrame<HistoricalState>[],
|
||||
queued: QueuedAction<Action>,
|
||||
tickRateHz: number,
|
||||
historyTicks: number,
|
||||
definition: LagCompensationDefinition<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
HistoricalState,
|
||||
Action
|
||||
>,
|
||||
): void {
|
||||
const policy = definition.actions[queued.action.type as Action["type"]] as
|
||||
| LagCompensationActionPolicy<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
>
|
||||
| undefined;
|
||||
if (!policy) return;
|
||||
|
||||
const maximumRewindTicks = Math.min(
|
||||
historyTicks,
|
||||
millisecondsToTicks(
|
||||
policy.maximumRewindMs ?? (historyTicks / tickRateHz) * 1_000,
|
||||
tickRateHz,
|
||||
),
|
||||
);
|
||||
const maximumFutureTicks = millisecondsToTicks(
|
||||
policy.maximumFutureMs ?? 0,
|
||||
tickRateHz,
|
||||
);
|
||||
const minimumTick = Math.max(0, tickContext.tick - maximumRewindTicks);
|
||||
const maximumTick = tickContext.tick + maximumFutureTicks;
|
||||
const outsideWindow =
|
||||
queued.requestedTick < minimumTick || queued.requestedTick > maximumTick;
|
||||
if (outsideWindow && policy.outOfWindow === "reject") return;
|
||||
|
||||
const boundedRequestedTick = clamp(
|
||||
queued.requestedTick,
|
||||
minimumTick,
|
||||
tickContext.tick,
|
||||
);
|
||||
let resolvedTick = tickContext.tick;
|
||||
let historicalState: HistoricalState;
|
||||
|
||||
if (policy.mode === "current") {
|
||||
historicalState = definition.cloneHistoricalState(
|
||||
definition.captureState(state, tickContext),
|
||||
);
|
||||
} else {
|
||||
const frame = frameAtOrBefore(frames, boundedRequestedTick);
|
||||
if (!frame) return;
|
||||
resolvedTick = frame.tick;
|
||||
historicalState = definition.cloneHistoricalState(frame.state);
|
||||
}
|
||||
|
||||
const resolutionContext: LagCompensationResolutionContext<
|
||||
AuthorityState,
|
||||
HistoricalState,
|
||||
Action,
|
||||
AuthorityEvent
|
||||
> = {
|
||||
currentState: state,
|
||||
historicalState,
|
||||
action: queued.action,
|
||||
playerId: queued.playerId,
|
||||
sequence: queued.sequence,
|
||||
requestedTick: queued.requestedTick,
|
||||
resolvedTick,
|
||||
currentTick: tickContext.tick,
|
||||
rewindTicks: Math.max(0, tickContext.tick - resolvedTick),
|
||||
catchUpTicks:
|
||||
policy.mode === "fast-forward"
|
||||
? Math.max(0, tickContext.tick - resolvedTick)
|
||||
: 0,
|
||||
clamped: resolvedTick !== queued.requestedTick,
|
||||
emit: tickContext.emit,
|
||||
};
|
||||
if (policy.validate && !policy.validate(resolutionContext)) return;
|
||||
policy.resolve(resolutionContext);
|
||||
}
|
||||
|
||||
function frameAtOrBefore<HistoricalState>(
|
||||
frames: readonly HistoricalFrame<HistoricalState>[],
|
||||
tick: number,
|
||||
): HistoricalFrame<HistoricalState> | undefined {
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
let selected: HistoricalFrame<HistoricalState> | undefined;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const frame = frames[middle]!;
|
||||
if (frame.tick <= tick) {
|
||||
selected = frame;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return selected ?? frames[0];
|
||||
}
|
||||
|
||||
function normalizeHistoryTicks(tickRateHz: number, seconds: number): number {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
throw new RangeError("historySeconds must be positive");
|
||||
}
|
||||
return Math.max(1, Math.ceil(seconds * tickRateHz));
|
||||
}
|
||||
|
||||
function validatePolicies(
|
||||
policies: Record<string, { mode: LagCompensationMode; maximumRewindMs?: number; maximumFutureMs?: number }>,
|
||||
): void {
|
||||
for (const [kind, policy] of Object.entries(policies)) {
|
||||
if (!["current", "rewind", "fast-forward"].includes(policy.mode)) {
|
||||
throw new RangeError(`Unknown lag compensation mode for ${kind}`);
|
||||
}
|
||||
for (const [name, value] of [
|
||||
["maximumRewindMs", policy.maximumRewindMs],
|
||||
["maximumFutureMs", policy.maximumFutureMs],
|
||||
] as const) {
|
||||
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
||||
throw new RangeError(`${name} for ${kind} must be non-negative`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function millisecondsToTicks(milliseconds: number, tickRateHz: number): number {
|
||||
return Math.max(0, Math.floor((milliseconds / 1_000) * tickRateHz));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
92
packages/engine/src/network-clock.ts
Normal file
92
packages/engine/src/network-clock.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { PingPacket, PongPacket } from "./types.js";
|
||||
|
||||
export interface NetworkStats {
|
||||
roundTripTime: number;
|
||||
jitter: number;
|
||||
clockOffset: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
interface ClockSample {
|
||||
roundTripTime: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export class NetworkClock {
|
||||
private nextPingId = 1;
|
||||
private readonly recentSamples: ClockSample[] = [];
|
||||
private smoothedRtt = 0;
|
||||
private smoothedJitter = 0;
|
||||
private offset = 0;
|
||||
|
||||
createPing(clientNow: number): PingPacket {
|
||||
const ping = {
|
||||
id: this.nextPingId,
|
||||
clientSentAt: clientNow,
|
||||
};
|
||||
|
||||
this.nextPingId = (this.nextPingId + 1) >>> 0;
|
||||
return ping;
|
||||
}
|
||||
|
||||
receivePong(pong: PongPacket, clientReceivedAt: number): NetworkStats {
|
||||
const serverProcessing = Math.max(
|
||||
0,
|
||||
pong.serverSentAt - pong.serverReceivedAt,
|
||||
);
|
||||
const roundTripTime = Math.max(
|
||||
0,
|
||||
clientReceivedAt - pong.clientSentAt - serverProcessing,
|
||||
);
|
||||
const offset =
|
||||
((pong.serverReceivedAt - pong.clientSentAt) +
|
||||
(pong.serverSentAt - clientReceivedAt)) /
|
||||
2;
|
||||
|
||||
const previousRtt = this.smoothedRtt;
|
||||
this.smoothedRtt =
|
||||
this.recentSamples.length === 0
|
||||
? roundTripTime
|
||||
: previousRtt * 0.8 + roundTripTime * 0.2;
|
||||
this.smoothedJitter =
|
||||
this.recentSamples.length === 0
|
||||
? 0
|
||||
: this.smoothedJitter * 0.8 +
|
||||
Math.abs(roundTripTime - previousRtt) * 0.2;
|
||||
|
||||
this.recentSamples.push({ roundTripTime, offset });
|
||||
if (this.recentSamples.length > 10) {
|
||||
this.recentSamples.shift();
|
||||
}
|
||||
|
||||
const bestSample = this.recentSamples.reduce((best, sample) =>
|
||||
sample.roundTripTime < best.roundTripTime ? sample : best,
|
||||
);
|
||||
this.offset = bestSample.offset;
|
||||
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
toServerTime(clientNow: number): number {
|
||||
return clientNow + this.offset;
|
||||
}
|
||||
|
||||
recommendedInputLeadTicks(tickRateHz: number, maximum = 8): number {
|
||||
const tickMilliseconds = 1_000 / tickRateHz;
|
||||
const latencyBudget = this.smoothedRtt / 2 + this.smoothedJitter * 2;
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(maximum, Math.ceil(latencyBudget / tickMilliseconds) + 1),
|
||||
);
|
||||
}
|
||||
|
||||
get stats(): NetworkStats {
|
||||
return {
|
||||
roundTripTime: this.smoothedRtt,
|
||||
jitter: this.smoothedJitter,
|
||||
clockOffset: this.offset,
|
||||
samples: this.recentSamples.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
214
packages/engine/src/networked-client.ts
Normal file
214
packages/engine/src/networked-client.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NetworkClock } from "./network-clock.js";
|
||||
import type {
|
||||
ClientStateReport,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
} from "./types.js";
|
||||
import type { NetworkedGameDefinition } from "./networked-types.js";
|
||||
|
||||
interface PredictedInput<Input> {
|
||||
packet: InputPacket<Input>;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export class NetworkedPredictedEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
readonly game: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
readonly networkClock = new NetworkClock();
|
||||
private state: ClientState;
|
||||
private playerId: PlayerId | null = null;
|
||||
private currentTick = 0;
|
||||
private nextInputSequence = 1;
|
||||
private acknowledgedSequence = 0;
|
||||
private pendingInputs: PredictedInput<Input>[] = [];
|
||||
|
||||
constructor(
|
||||
game: NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
) {
|
||||
this.game = game;
|
||||
this.state = game.client.createInitialState();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get localPlayerId(): PlayerId | null {
|
||||
return this.playerId;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<ClientState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
get initialized(): boolean {
|
||||
return this.playerId !== null;
|
||||
}
|
||||
|
||||
initialize(playerId: PlayerId, snapshot: StateSnapshot<ClientState>): void {
|
||||
this.playerId = playerId;
|
||||
this.currentTick = snapshot.tick;
|
||||
this.state = this.game.client.cloneState(snapshot.state);
|
||||
this.pendingInputs = [];
|
||||
this.acknowledgedSequence = 0;
|
||||
}
|
||||
|
||||
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
|
||||
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 = {
|
||||
sequence: this.nextInputSequence,
|
||||
targetTick,
|
||||
observedTick: this.currentTick,
|
||||
input,
|
||||
};
|
||||
|
||||
if (!this.game.validateInput(input, this.inputContext(packet))) {
|
||||
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.client.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
});
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
reconcile(snapshot: StateSnapshot<ClientState>): void {
|
||||
if (
|
||||
this.playerId === null ||
|
||||
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const predictedTick = Math.max(this.currentTick, snapshot.tick);
|
||||
const context = {
|
||||
tick: snapshot.tick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
};
|
||||
this.state = this.game.client.mergeSnapshot
|
||||
? this.game.client.mergeSnapshot(this.state, snapshot.state, context)
|
||||
: this.game.client.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.client.step(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
});
|
||||
}
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
receiveEvent(event: PerceptionEvent, tick: number): void {
|
||||
this.game.client.applyEvent?.(this.state, event, { tick });
|
||||
this.assertValidState();
|
||||
}
|
||||
|
||||
createStateReport(): ClientStateReport<ClientState> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
state: this.game.client.cloneState(this.state),
|
||||
};
|
||||
}
|
||||
|
||||
private applyDueInputs(): void {
|
||||
for (const pending of this.pendingInputs) {
|
||||
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
|
||||
this.game.client.applyInput(
|
||||
this.state,
|
||||
pending.packet.input,
|
||||
this.inputContext(pending.packet),
|
||||
);
|
||||
pending.applied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inputContext(packet: InputPacket<Input>): 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.client.validateState &&
|
||||
!this.game.client.validateState(this.state, {
|
||||
tick: this.currentTick,
|
||||
deltaSeconds: 1 / this.game.tickRateHz,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`Client prediction produced invalid state at tick ${this.currentTick}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
472
packages/engine/src/networked-server.ts
Normal file
472
packages/engine/src/networked-server.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
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<AuthorityEvent> {
|
||||
tick: number;
|
||||
acknowledgements: Acknowledgement[];
|
||||
validations: ValidationResult[];
|
||||
events: AuthorityEvent[];
|
||||
snapshotDue: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkedSimulationCheckpoint<
|
||||
AuthorityState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
> {
|
||||
tick: number;
|
||||
state: AuthorityState;
|
||||
playerIds: PlayerId[];
|
||||
lastReceivedSequences: Array<readonly [PlayerId, number]>;
|
||||
acknowledgedSequences: Array<readonly [PlayerId, number]>;
|
||||
queuedInputs: Array<{
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}>;
|
||||
pendingEvents: AuthorityEvent[];
|
||||
}
|
||||
|
||||
interface QueuedInput<Input> {
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}
|
||||
|
||||
interface QueuedReport<ClientState> {
|
||||
playerId: PlayerId;
|
||||
report: ClientStateReport<ClientState>;
|
||||
}
|
||||
|
||||
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<PlayerId>();
|
||||
private readonly lastReceivedSequence = new Map<PlayerId, number>();
|
||||
private readonly acknowledgedSequence = new Map<PlayerId, number>();
|
||||
private readonly history = new Map<number, AuthorityState>();
|
||||
private queuedInputs: QueuedInput<Input>[] = [];
|
||||
private queuedReports: QueuedReport<ClientState>[] = [];
|
||||
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<AuthorityState> {
|
||||
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<Input>): 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<ClientState>,
|
||||
): 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<AuthorityEvent> {
|
||||
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<PlayerId, number>();
|
||||
const dueInputs: QueuedInput<Input>[] = [];
|
||||
const futureInputs: QueuedInput<Input>[] = [];
|
||||
|
||||
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<ClientState>[] = [];
|
||||
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<ClientState> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
serverTime,
|
||||
state: this.game.replication.createSnapshot(this.state, {
|
||||
playerId,
|
||||
tick: this.currentTick,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
|
||||
const groups = new Map<string | number, PlayerId[]>();
|
||||
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<Input>,
|
||||
): 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<Input>): InputPacket<Input> {
|
||||
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<ClientState>,
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
packages/engine/src/networked-types.ts
Normal file
125
packages/engine/src/networked-types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
BinaryCodec,
|
||||
ClientStateContext,
|
||||
InputContext,
|
||||
PlayerContext,
|
||||
PlayerId,
|
||||
TickContext,
|
||||
} from "./types.js";
|
||||
|
||||
export interface EmittingTickContext<Event> extends TickContext {
|
||||
emit(event: Event): void;
|
||||
}
|
||||
|
||||
export interface EmittingInputContext<Event> extends InputContext {
|
||||
emit(event: Event): void;
|
||||
}
|
||||
|
||||
export interface EmittingPlayerContext<Event> extends PlayerContext {
|
||||
emit(event: Event): void;
|
||||
}
|
||||
|
||||
export interface ClientEventContext {
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export interface ServerSimulationRules<AuthorityState, Input, AuthorityEvent> {
|
||||
createInitialState(): AuthorityState;
|
||||
/** Runs after all higher-order initializers have finished creating state. */
|
||||
initializeState?(state: AuthorityState, context: TickContext): void;
|
||||
cloneState(state: AuthorityState): AuthorityState;
|
||||
/** Optional cheaper clone used only for client-state validation history. */
|
||||
cloneStateForHistory?(state: AuthorityState): AuthorityState;
|
||||
step(
|
||||
state: AuthorityState,
|
||||
context: EmittingTickContext<AuthorityEvent>,
|
||||
): void;
|
||||
applyInput(
|
||||
state: AuthorityState,
|
||||
input: Input,
|
||||
context: EmittingInputContext<AuthorityEvent>,
|
||||
): void;
|
||||
validateState?(state: AuthorityState, context: TickContext): boolean;
|
||||
addPlayer?(
|
||||
state: AuthorityState,
|
||||
context: EmittingPlayerContext<AuthorityEvent>,
|
||||
): void;
|
||||
removePlayer?(
|
||||
state: AuthorityState,
|
||||
context: EmittingPlayerContext<AuthorityEvent>,
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface ClientSimulationRules<ClientState, Input, PerceptionEvent> {
|
||||
createInitialState(): ClientState;
|
||||
cloneState(state: ClientState): ClientState;
|
||||
step(state: ClientState, context: TickContext): void;
|
||||
applyInput(state: ClientState, input: Input, context: InputContext): void;
|
||||
validateState?(state: ClientState, context: TickContext): boolean;
|
||||
mergeSnapshot?(
|
||||
predicted: ClientState,
|
||||
snapshot: ClientState,
|
||||
context: TickContext,
|
||||
): ClientState;
|
||||
applyEvent?(
|
||||
state: ClientState,
|
||||
event: PerceptionEvent,
|
||||
context: ClientEventContext,
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface ReplicationRules<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
createSnapshot(
|
||||
authoritative: AuthorityState,
|
||||
context: PlayerContext,
|
||||
): ClientState;
|
||||
validateClientState?(
|
||||
authoritative: AuthorityState,
|
||||
candidate: ClientState,
|
||||
context: ClientStateContext,
|
||||
): boolean;
|
||||
perceive?(
|
||||
authoritative: AuthorityState,
|
||||
event: AuthorityEvent,
|
||||
context: PlayerContext,
|
||||
): PerceptionEvent | null;
|
||||
groupKey?(
|
||||
authoritative: AuthorityState,
|
||||
context: PlayerContext,
|
||||
): string | number;
|
||||
}
|
||||
|
||||
export interface NetworkedGameDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent = never,
|
||||
PerceptionEvent = never,
|
||||
> {
|
||||
tickRateHz: number;
|
||||
snapshotRateHz: number;
|
||||
server: ServerSimulationRules<AuthorityState, Input, AuthorityEvent>;
|
||||
client: ClientSimulationRules<ClientState, Input, PerceptionEvent>;
|
||||
replication: ReplicationRules<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
validateInput(input: Input, context: InputContext): boolean;
|
||||
codecs: {
|
||||
input: BinaryCodec<Input>;
|
||||
state: BinaryCodec<ClientState>;
|
||||
event?: BinaryCodec<PerceptionEvent>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SnapshotBatch<ClientState> {
|
||||
playerIds: PlayerId[];
|
||||
state: ClientState;
|
||||
}
|
||||
401
packages/engine/src/protocol.ts
Normal file
401
packages/engine/src/protocol.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import type {
|
||||
BinaryCodec,
|
||||
ClientStateReport,
|
||||
InputPacket,
|
||||
PingPacket,
|
||||
PlayerId,
|
||||
PongPacket,
|
||||
StateSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
const enum ServerOpcode {
|
||||
Welcome = 1,
|
||||
Snapshot = 2,
|
||||
Acknowledge = 3,
|
||||
Pong = 4,
|
||||
Validation = 5,
|
||||
RejectInput = 6,
|
||||
Event = 7,
|
||||
ReplayStart = 8,
|
||||
ReplayFrame = 9,
|
||||
ReplayEnd = 10,
|
||||
}
|
||||
|
||||
const enum ClientOpcode {
|
||||
Input = 16,
|
||||
Ping = 17,
|
||||
StateReport = 18,
|
||||
}
|
||||
|
||||
export type ClientWireMessage<Input, State> =
|
||||
| { kind: "input"; packet: InputPacket<Input> }
|
||||
| { kind: "ping"; ping: PingPacket }
|
||||
| { kind: "state-report"; report: ClientStateReport<State> };
|
||||
|
||||
export type ServerWireMessage<State, Event = never> =
|
||||
| { kind: "welcome"; playerId: PlayerId; snapshot: StateSnapshot<State> }
|
||||
| { kind: "snapshot"; snapshot: StateSnapshot<State> }
|
||||
| { kind: "acknowledge"; sequence: number }
|
||||
| { kind: "pong"; pong: PongPacket }
|
||||
| { kind: "validation"; tick: number; valid: boolean }
|
||||
| { kind: "reject-input"; sequence: number }
|
||||
| { kind: "event"; tick: number; event: Event }
|
||||
| {
|
||||
kind: "replay-start";
|
||||
ticketId: number;
|
||||
perspectiveId: PlayerId;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
frameCount: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
| {
|
||||
kind: "replay-frame";
|
||||
ticketId: number;
|
||||
tick: number;
|
||||
state: State;
|
||||
events: Event[];
|
||||
}
|
||||
| { kind: "replay-end"; ticketId: number };
|
||||
|
||||
export interface BinaryProtocol<Input, State, Event = never> {
|
||||
encodeClient(message: ClientWireMessage<Input, State>): ArrayBuffer;
|
||||
decodeClient(payload: ArrayBuffer): ClientWireMessage<Input, State>;
|
||||
encodeServer(message: ServerWireMessage<State, Event>): ArrayBuffer;
|
||||
decodeServer(payload: ArrayBuffer): ServerWireMessage<State, Event>;
|
||||
}
|
||||
|
||||
export function createBinaryProtocol<Input, State, Event = never>(codecs: {
|
||||
input: BinaryCodec<Input>;
|
||||
state: BinaryCodec<State>;
|
||||
event?: BinaryCodec<Event>;
|
||||
}): BinaryProtocol<Input, State, Event> {
|
||||
return {
|
||||
encodeClient(message) {
|
||||
switch (message.kind) {
|
||||
case "input": {
|
||||
const input = codecs.input.encode(message.packet.input);
|
||||
const payload = createFrame(ClientOpcode.Input, 13, input);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.packet.sequence, true);
|
||||
view.setUint32(5, message.packet.targetTick, true);
|
||||
view.setUint32(
|
||||
9,
|
||||
message.packet.observedTick ?? message.packet.targetTick,
|
||||
true,
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
case "ping": {
|
||||
const payload = createFrame(ClientOpcode.Ping, 13);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.ping.id, true);
|
||||
view.setFloat64(5, message.ping.clientSentAt, true);
|
||||
return payload;
|
||||
}
|
||||
case "state-report": {
|
||||
const state = codecs.state.encode(message.report.state);
|
||||
const payload = createFrame(ClientOpcode.StateReport, 5, state);
|
||||
new DataView(payload).setUint32(1, message.report.tick, true);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
},
|
||||
decodeClient(payload) {
|
||||
const view = new DataView(payload);
|
||||
switch (view.getUint8(0)) {
|
||||
case ClientOpcode.Input:
|
||||
assertLength(payload, 13);
|
||||
return {
|
||||
kind: "input",
|
||||
packet: {
|
||||
sequence: view.getUint32(1, true),
|
||||
targetTick: view.getUint32(5, true),
|
||||
observedTick: view.getUint32(9, true),
|
||||
input: codecs.input.decode(new Uint8Array(payload, 13)),
|
||||
},
|
||||
};
|
||||
case ClientOpcode.Ping:
|
||||
assertExactLength(payload, 13);
|
||||
return {
|
||||
kind: "ping",
|
||||
ping: {
|
||||
id: view.getUint32(1, true),
|
||||
clientSentAt: view.getFloat64(5, true),
|
||||
},
|
||||
};
|
||||
case ClientOpcode.StateReport:
|
||||
assertLength(payload, 5);
|
||||
return {
|
||||
kind: "state-report",
|
||||
report: {
|
||||
tick: view.getUint32(1, true),
|
||||
state: codecs.state.decode(new Uint8Array(payload, 5)),
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new RangeError("Unknown client message opcode");
|
||||
}
|
||||
},
|
||||
encodeServer(message) {
|
||||
switch (message.kind) {
|
||||
case "welcome": {
|
||||
const state = codecs.state.encode(message.snapshot.state);
|
||||
const payload = createFrame(ServerOpcode.Welcome, 17, state);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.playerId, true);
|
||||
view.setUint32(5, message.snapshot.tick, true);
|
||||
view.setFloat64(9, message.snapshot.serverTime, true);
|
||||
return payload;
|
||||
}
|
||||
case "snapshot": {
|
||||
const state = codecs.state.encode(message.snapshot.state);
|
||||
const payload = createFrame(ServerOpcode.Snapshot, 13, state);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.snapshot.tick, true);
|
||||
view.setFloat64(5, message.snapshot.serverTime, true);
|
||||
return payload;
|
||||
}
|
||||
case "acknowledge": {
|
||||
const payload = createFrame(ServerOpcode.Acknowledge, 5);
|
||||
new DataView(payload).setUint32(1, message.sequence, true);
|
||||
return payload;
|
||||
}
|
||||
case "pong": {
|
||||
const payload = createFrame(ServerOpcode.Pong, 29);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.pong.id, true);
|
||||
view.setFloat64(5, message.pong.clientSentAt, true);
|
||||
view.setFloat64(13, message.pong.serverReceivedAt, true);
|
||||
view.setFloat64(21, message.pong.serverSentAt, true);
|
||||
return payload;
|
||||
}
|
||||
case "validation": {
|
||||
const payload = createFrame(ServerOpcode.Validation, 6);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.tick, true);
|
||||
view.setUint8(5, message.valid ? 1 : 0);
|
||||
return payload;
|
||||
}
|
||||
case "reject-input": {
|
||||
const payload = createFrame(ServerOpcode.RejectInput, 5);
|
||||
new DataView(payload).setUint32(1, message.sequence, true);
|
||||
return payload;
|
||||
}
|
||||
case "event": {
|
||||
if (!codecs.event) {
|
||||
throw new Error("This protocol has no event codec");
|
||||
}
|
||||
const event = codecs.event.encode(message.event);
|
||||
const payload = createFrame(ServerOpcode.Event, 5, event);
|
||||
new DataView(payload).setUint32(1, message.tick, true);
|
||||
return payload;
|
||||
}
|
||||
case "replay-start": {
|
||||
if (message.frameCount > 65_535) {
|
||||
throw new RangeError("Replay frame count exceeds protocol limit");
|
||||
}
|
||||
const payload = createFrame(ServerOpcode.ReplayStart, 23);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.ticketId, true);
|
||||
view.setUint32(5, message.perspectiveId, true);
|
||||
view.setUint32(9, message.fromTick, true);
|
||||
view.setUint32(13, message.toTick, true);
|
||||
view.setUint16(17, message.frameCount, true);
|
||||
view.setFloat32(19, message.playbackRate, true);
|
||||
return payload;
|
||||
}
|
||||
case "replay-frame": {
|
||||
if (message.events.length > 65_535) {
|
||||
throw new RangeError("Replay event count exceeds protocol limit");
|
||||
}
|
||||
if (message.events.length > 0 && !codecs.event) {
|
||||
throw new Error("This protocol has no event codec");
|
||||
}
|
||||
const state = codecs.state.encode(message.state);
|
||||
const events = message.events.map((event) => codecs.event!.encode(event));
|
||||
const bodyLength =
|
||||
state.byteLength +
|
||||
events.reduce((total, event) => total + 4 + event.byteLength, 0);
|
||||
const payload = createFrame(ServerOpcode.ReplayFrame, 15 + bodyLength);
|
||||
const view = new DataView(payload);
|
||||
view.setUint32(1, message.ticketId, true);
|
||||
view.setUint32(5, message.tick, true);
|
||||
view.setUint16(9, events.length, true);
|
||||
view.setUint32(11, state.byteLength, true);
|
||||
const bytes = new Uint8Array(payload);
|
||||
let offset = 15;
|
||||
bytes.set(state, offset);
|
||||
offset += state.byteLength;
|
||||
for (const event of events) {
|
||||
view.setUint32(offset, event.byteLength, true);
|
||||
offset += 4;
|
||||
bytes.set(event, offset);
|
||||
offset += event.byteLength;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
case "replay-end": {
|
||||
const payload = createFrame(ServerOpcode.ReplayEnd, 5);
|
||||
new DataView(payload).setUint32(1, message.ticketId, true);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
},
|
||||
decodeServer(payload) {
|
||||
const view = new DataView(payload);
|
||||
switch (view.getUint8(0)) {
|
||||
case ServerOpcode.Welcome:
|
||||
assertLength(payload, 17);
|
||||
return {
|
||||
kind: "welcome",
|
||||
playerId: view.getUint32(1, true),
|
||||
snapshot: {
|
||||
tick: view.getUint32(5, true),
|
||||
serverTime: view.getFloat64(9, true),
|
||||
state: codecs.state.decode(new Uint8Array(payload, 17)),
|
||||
},
|
||||
};
|
||||
case ServerOpcode.Snapshot:
|
||||
assertLength(payload, 13);
|
||||
return {
|
||||
kind: "snapshot",
|
||||
snapshot: {
|
||||
tick: view.getUint32(1, true),
|
||||
serverTime: view.getFloat64(5, true),
|
||||
state: codecs.state.decode(new Uint8Array(payload, 13)),
|
||||
},
|
||||
};
|
||||
case ServerOpcode.Acknowledge:
|
||||
assertExactLength(payload, 5);
|
||||
return {
|
||||
kind: "acknowledge",
|
||||
sequence: view.getUint32(1, true),
|
||||
};
|
||||
case ServerOpcode.Pong:
|
||||
assertExactLength(payload, 29);
|
||||
return {
|
||||
kind: "pong",
|
||||
pong: {
|
||||
id: view.getUint32(1, true),
|
||||
clientSentAt: view.getFloat64(5, true),
|
||||
serverReceivedAt: view.getFloat64(13, true),
|
||||
serverSentAt: view.getFloat64(21, true),
|
||||
},
|
||||
};
|
||||
case ServerOpcode.Validation:
|
||||
assertExactLength(payload, 6);
|
||||
return {
|
||||
kind: "validation",
|
||||
tick: view.getUint32(1, true),
|
||||
valid: view.getUint8(5) === 1,
|
||||
};
|
||||
case ServerOpcode.RejectInput:
|
||||
assertExactLength(payload, 5);
|
||||
return {
|
||||
kind: "reject-input",
|
||||
sequence: view.getUint32(1, true),
|
||||
};
|
||||
case ServerOpcode.Event:
|
||||
assertLength(payload, 5);
|
||||
if (!codecs.event) {
|
||||
throw new Error("This protocol has no event codec");
|
||||
}
|
||||
return {
|
||||
kind: "event",
|
||||
tick: view.getUint32(1, true),
|
||||
event: codecs.event.decode(new Uint8Array(payload, 5)),
|
||||
};
|
||||
case ServerOpcode.ReplayStart:
|
||||
assertExactLength(payload, 23);
|
||||
return {
|
||||
kind: "replay-start",
|
||||
ticketId: view.getUint32(1, true),
|
||||
perspectiveId: view.getUint32(5, true),
|
||||
fromTick: view.getUint32(9, true),
|
||||
toTick: view.getUint32(13, true),
|
||||
frameCount: view.getUint16(17, true),
|
||||
playbackRate: view.getFloat32(19, true),
|
||||
};
|
||||
case ServerOpcode.ReplayFrame: {
|
||||
assertLength(payload, 15);
|
||||
const ticketId = view.getUint32(1, true);
|
||||
const tick = view.getUint32(5, true);
|
||||
const eventCount = view.getUint16(9, true);
|
||||
const stateLength = view.getUint32(11, true);
|
||||
assertAvailable(payload, 15, stateLength);
|
||||
const state = codecs.state.decode(
|
||||
new Uint8Array(payload, 15, stateLength),
|
||||
);
|
||||
let offset = 15 + stateLength;
|
||||
const events: Event[] = [];
|
||||
for (let index = 0; index < eventCount; index += 1) {
|
||||
if (!codecs.event) throw new Error("This protocol has no event codec");
|
||||
assertAvailable(payload, offset, 4);
|
||||
const eventLength = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
assertAvailable(payload, offset, eventLength);
|
||||
events.push(
|
||||
codecs.event.decode(new Uint8Array(payload, offset, eventLength)),
|
||||
);
|
||||
offset += eventLength;
|
||||
}
|
||||
if (offset !== payload.byteLength) {
|
||||
throw new RangeError("Replay frame has trailing bytes");
|
||||
}
|
||||
return { kind: "replay-frame", ticketId, tick, state, events };
|
||||
}
|
||||
case ServerOpcode.ReplayEnd:
|
||||
assertExactLength(payload, 5);
|
||||
return {
|
||||
kind: "replay-end",
|
||||
ticketId: view.getUint32(1, true),
|
||||
};
|
||||
default:
|
||||
throw new RangeError("Unknown server message opcode");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFrame(
|
||||
opcode: number,
|
||||
headerLength: number,
|
||||
body?: Uint8Array,
|
||||
): ArrayBuffer {
|
||||
const payload = new ArrayBuffer(headerLength + (body?.byteLength ?? 0));
|
||||
const bytes = new Uint8Array(payload);
|
||||
bytes[0] = opcode;
|
||||
if (body) {
|
||||
bytes.set(body, headerLength);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function assertLength(payload: ArrayBuffer, minimum: number): void {
|
||||
if (payload.byteLength < minimum) {
|
||||
throw new RangeError(`Message must be at least ${minimum} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactLength(payload: ArrayBuffer, expected: number): void {
|
||||
if (payload.byteLength !== expected) {
|
||||
throw new RangeError(`Message must be exactly ${expected} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAvailable(
|
||||
payload: ArrayBuffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
): void {
|
||||
if (
|
||||
!Number.isInteger(offset) ||
|
||||
!Number.isInteger(length) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
offset > payload.byteLength - length
|
||||
) {
|
||||
throw new RangeError("Message body is truncated");
|
||||
}
|
||||
}
|
||||
482
packages/engine/src/replay-transport.ts
Normal file
482
packages/engine/src/replay-transport.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import type { DefinedNetworkedGame } from "./define-networked-game.js";
|
||||
import type { NetworkedServerStepResult } from "./networked-server.js";
|
||||
import type { SnapshotBatch } from "./networked-types.js";
|
||||
import type { InputDecision } from "./server.js";
|
||||
import {
|
||||
type ReplayRecording,
|
||||
type ReplayStateHash,
|
||||
type TimeTravelAuthoritativeEngine,
|
||||
type TimeTravelNetworkedGame,
|
||||
type TimeTravelServerOptions,
|
||||
} from "./time-travel.js";
|
||||
import type {
|
||||
ClientStateReport,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export interface ProjectedReplayFrame<ClientState, PerceptionEvent> {
|
||||
tick: number;
|
||||
state: ClientState;
|
||||
events: PerceptionEvent[];
|
||||
}
|
||||
|
||||
export interface ReplayTicket<ClientState, PerceptionEvent> {
|
||||
ticketId: number;
|
||||
requesterId: PlayerId;
|
||||
perspectiveId: PlayerId;
|
||||
issuedAtTick: number;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
playbackRate: number;
|
||||
frames: Array<ProjectedReplayFrame<ClientState, PerceptionEvent>>;
|
||||
}
|
||||
|
||||
export interface ReplayTicketPlan {
|
||||
requesterId: PlayerId;
|
||||
perspectiveId: PlayerId;
|
||||
fromTick: number;
|
||||
toTick: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export interface ReplayTriggerContext<AuthorityState> {
|
||||
tick: number;
|
||||
authorityState: Readonly<AuthorityState>;
|
||||
connectedPlayerIds: readonly PlayerId[];
|
||||
}
|
||||
|
||||
export interface ReplayAuthorizationContext<AuthorityState>
|
||||
extends ReplayTicketPlan {
|
||||
currentTick: number;
|
||||
authorityState: Readonly<AuthorityState>;
|
||||
connectedPlayerIds: readonly PlayerId[];
|
||||
}
|
||||
|
||||
export interface ReplayTransportDefinition<AuthorityState, AuthorityEvent> {
|
||||
/** Maximum age of the projected frame ring and private deterministic log. */
|
||||
historySeconds: number;
|
||||
/** Baseline frame rate. Event ticks are captured even between baseline frames. */
|
||||
captureRateHz?: number;
|
||||
/** Perspectives worth retaining. Defaults to connected network players. */
|
||||
listPerspectives?(
|
||||
authorityState: Readonly<AuthorityState>,
|
||||
connectedPlayerIds: readonly PlayerId[],
|
||||
): Iterable<PlayerId>;
|
||||
/** Trusted server hook that can create automatic tickets from authority events. */
|
||||
createTickets?(
|
||||
event: Readonly<AuthorityEvent>,
|
||||
context: ReplayTriggerContext<AuthorityState>,
|
||||
): ReplayTicketPlan | readonly ReplayTicketPlan[] | null;
|
||||
/** Every automatic or manual ticket passes through this policy. */
|
||||
authorizeReplay?(
|
||||
context: ReplayAuthorizationContext<AuthorityState>,
|
||||
): boolean;
|
||||
maxPendingTicketsPerPlayer?: number;
|
||||
}
|
||||
|
||||
interface NormalizedReplayTransportDefinition<AuthorityState, AuthorityEvent>
|
||||
extends ReplayTransportDefinition<AuthorityState, AuthorityEvent> {
|
||||
historyTicks: number;
|
||||
captureEveryTicks: number;
|
||||
maxPendingTicketsPerPlayer: number;
|
||||
}
|
||||
|
||||
export type ReplayTransportNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash extends ReplayStateHash,
|
||||
> = Omit<
|
||||
TimeTravelNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>,
|
||||
"createServer"
|
||||
> & {
|
||||
createServer(
|
||||
options?: TimeTravelServerOptions<Seed>,
|
||||
): ReplayTransportAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps client-safe historical projections around a private authoritative
|
||||
* engine. Raw authority checkpoints never cross this boundary.
|
||||
*/
|
||||
export class ReplayTransportAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash extends ReplayStateHash,
|
||||
> {
|
||||
readonly game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>;
|
||||
private readonly engine: TimeTravelAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>;
|
||||
private readonly replay: NormalizedReplayTransportDefinition<
|
||||
AuthorityState,
|
||||
AuthorityEvent
|
||||
>;
|
||||
private readonly history = new Map<
|
||||
PlayerId,
|
||||
Array<ProjectedReplayFrame<ClientState, PerceptionEvent>>
|
||||
>();
|
||||
private readonly pendingTickets = new Map<
|
||||
PlayerId,
|
||||
Array<ReplayTicket<ClientState, PerceptionEvent>>
|
||||
>();
|
||||
private nextTicketId = 1;
|
||||
|
||||
constructor(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
engine: TimeTravelAuthoritativeEngine<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>,
|
||||
replay: NormalizedReplayTransportDefinition<
|
||||
AuthorityState,
|
||||
AuthorityEvent
|
||||
>,
|
||||
) {
|
||||
this.game = game;
|
||||
this.engine = engine;
|
||||
this.replay = replay;
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.engine.tick;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<AuthorityState> {
|
||||
return this.engine.currentState;
|
||||
}
|
||||
|
||||
get playerIds(): PlayerId[] {
|
||||
return this.engine.playerIds;
|
||||
}
|
||||
|
||||
addPlayer(playerId: PlayerId): void {
|
||||
this.engine.addPlayer(playerId);
|
||||
this.capture([], true);
|
||||
}
|
||||
|
||||
removePlayer(playerId: PlayerId): void {
|
||||
this.engine.removePlayer(playerId);
|
||||
this.pendingTickets.delete(playerId);
|
||||
this.capture([], true);
|
||||
}
|
||||
|
||||
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
|
||||
return this.engine.submitInput(playerId, packet);
|
||||
}
|
||||
|
||||
submitStateReport(
|
||||
playerId: PlayerId,
|
||||
report: ClientStateReport<ClientState>,
|
||||
): ValidationResult | null {
|
||||
return this.engine.submitStateReport(playerId, report);
|
||||
}
|
||||
|
||||
step(): NetworkedServerStepResult<AuthorityEvent> {
|
||||
const result = this.engine.step();
|
||||
const shouldCapture =
|
||||
result.tick % this.replay.captureEveryTicks === 0 ||
|
||||
result.events.length > 0;
|
||||
if (shouldCapture) this.capture(result.events, true);
|
||||
this.pruneHistory();
|
||||
|
||||
if (this.replay.createTickets) {
|
||||
const context: ReplayTriggerContext<AuthorityState> = {
|
||||
tick: result.tick,
|
||||
authorityState: this.currentState,
|
||||
connectedPlayerIds: this.playerIds,
|
||||
};
|
||||
for (const event of result.events) {
|
||||
const plans = this.replay.createTickets(event, context);
|
||||
if (!plans) continue;
|
||||
for (const plan of Array.isArray(plans) ? plans : [plans]) {
|
||||
this.issueReplay(plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
createSnapshot(
|
||||
playerId: PlayerId,
|
||||
serverTime: number,
|
||||
): StateSnapshot<ClientState> {
|
||||
return this.engine.createSnapshot(playerId, serverTime);
|
||||
}
|
||||
|
||||
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
|
||||
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 this.engine.exportRecording();
|
||||
}
|
||||
|
||||
/** Trusted-server API. The configured authorization policy still applies. */
|
||||
issueReplay(
|
||||
plan: ReplayTicketPlan,
|
||||
): ReplayTicket<ClientState, PerceptionEvent> | null {
|
||||
if (!this.playerIds.includes(plan.requesterId)) return null;
|
||||
if (!validTick(plan.fromTick) || !validTick(plan.toTick)) return null;
|
||||
if (plan.toTick < plan.fromTick || plan.toTick > this.tick) return null;
|
||||
|
||||
const authorization: ReplayAuthorizationContext<AuthorityState> = {
|
||||
...plan,
|
||||
currentTick: this.tick,
|
||||
authorityState: this.currentState,
|
||||
connectedPlayerIds: this.playerIds,
|
||||
};
|
||||
const authorized = this.replay.authorizeReplay
|
||||
? this.replay.authorizeReplay(authorization)
|
||||
: plan.requesterId === plan.perspectiveId;
|
||||
if (!authorized) return null;
|
||||
|
||||
const perspectiveHistory = this.history.get(plan.perspectiveId);
|
||||
if (!perspectiveHistory) return null;
|
||||
const selected = perspectiveHistory.filter(
|
||||
({ tick }) => tick >= plan.fromTick && tick <= plan.toTick,
|
||||
);
|
||||
if (selected.length === 0) return null;
|
||||
|
||||
const first = selected[0]!;
|
||||
const last = selected[selected.length - 1]!;
|
||||
const ticket: ReplayTicket<ClientState, PerceptionEvent> = {
|
||||
ticketId: this.allocateTicketId(),
|
||||
requesterId: plan.requesterId,
|
||||
perspectiveId: plan.perspectiveId,
|
||||
issuedAtTick: this.tick,
|
||||
fromTick: first.tick,
|
||||
toTick: last.tick,
|
||||
playbackRate: clamp(plan.playbackRate ?? 1, 0.1, 4),
|
||||
frames: selected.map((frame) => this.cloneFrame(frame)),
|
||||
};
|
||||
|
||||
const queue = this.pendingTickets.get(plan.requesterId) ?? [];
|
||||
queue.push(ticket);
|
||||
while (queue.length > this.replay.maxPendingTicketsPerPlayer) queue.shift();
|
||||
this.pendingTickets.set(plan.requesterId, queue);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
drainReplayTickets(
|
||||
playerId: PlayerId,
|
||||
): Array<ReplayTicket<ClientState, PerceptionEvent>> {
|
||||
const tickets = this.pendingTickets.get(playerId) ?? [];
|
||||
this.pendingTickets.delete(playerId);
|
||||
return tickets;
|
||||
}
|
||||
|
||||
private capture(events: readonly AuthorityEvent[], replaceSameTick: boolean): void {
|
||||
const connected = this.playerIds;
|
||||
const perspectives = this.replay.listPerspectives
|
||||
? this.replay.listPerspectives(this.currentState, connected)
|
||||
: connected;
|
||||
const uniquePerspectives = new Set(perspectives);
|
||||
|
||||
for (const perspectiveId of uniquePerspectives) {
|
||||
if (!validPlayerId(perspectiveId)) continue;
|
||||
const snapshot = this.engine.createSnapshot(perspectiveId, 0);
|
||||
const perceptions = this.engine.createPerceptions(perspectiveId, events);
|
||||
const frame: ProjectedReplayFrame<ClientState, PerceptionEvent> = {
|
||||
tick: this.tick,
|
||||
state: this.cloneState(snapshot.state),
|
||||
events: perceptions.map((event) => this.cloneEvent(event)),
|
||||
};
|
||||
const frames = this.history.get(perspectiveId) ?? [];
|
||||
if (replaceSameTick && frames.at(-1)?.tick === this.tick) {
|
||||
frames[frames.length - 1] = frame;
|
||||
} else {
|
||||
frames.push(frame);
|
||||
}
|
||||
this.history.set(perspectiveId, frames);
|
||||
}
|
||||
}
|
||||
|
||||
private pruneHistory(): void {
|
||||
const earliestTick = Math.max(0, this.tick - this.replay.historyTicks);
|
||||
for (const [perspectiveId, frames] of this.history) {
|
||||
const firstRetained = frames.findIndex(({ tick }) => tick >= earliestTick);
|
||||
if (firstRetained === -1) {
|
||||
this.history.delete(perspectiveId);
|
||||
continue;
|
||||
}
|
||||
if (firstRetained > 0) frames.splice(0, firstRetained);
|
||||
}
|
||||
}
|
||||
|
||||
private cloneFrame(
|
||||
frame: ProjectedReplayFrame<ClientState, PerceptionEvent>,
|
||||
): ProjectedReplayFrame<ClientState, PerceptionEvent> {
|
||||
return {
|
||||
tick: frame.tick,
|
||||
state: this.cloneState(frame.state),
|
||||
events: frame.events.map((event) => this.cloneEvent(event)),
|
||||
};
|
||||
}
|
||||
|
||||
private cloneState(state: ClientState): ClientState {
|
||||
return this.game.codecs.state.decode(this.game.codecs.state.encode(state));
|
||||
}
|
||||
|
||||
private cloneEvent(event: PerceptionEvent): PerceptionEvent {
|
||||
const codec = this.game.codecs.event;
|
||||
if (!codec) {
|
||||
throw new Error("Replay perceptions require an event codec");
|
||||
}
|
||||
return codec.decode(codec.encode(event));
|
||||
}
|
||||
|
||||
private allocateTicketId(): number {
|
||||
const ticketId = this.nextTicketId;
|
||||
this.nextTicketId = this.nextTicketId === 0xffff_ffff
|
||||
? 1
|
||||
: this.nextTicketId + 1;
|
||||
return ticketId;
|
||||
}
|
||||
}
|
||||
|
||||
export function withReplayTransport<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash extends ReplayStateHash,
|
||||
>(
|
||||
game: TimeTravelNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
>,
|
||||
definition: ReplayTransportDefinition<AuthorityState, AuthorityEvent>,
|
||||
): ReplayTransportNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
Seed,
|
||||
StateHash
|
||||
> {
|
||||
const replay = normalizeReplayDefinition(game, definition);
|
||||
|
||||
return Object.freeze({
|
||||
...game,
|
||||
createServer(options: TimeTravelServerOptions<Seed> = {}) {
|
||||
const engine = game.createServer({
|
||||
...options,
|
||||
recordingHistoryTicks:
|
||||
options.recordingHistoryTicks ?? replay.historyTicks,
|
||||
});
|
||||
return new ReplayTransportAuthoritativeEngine(engine.game, engine, replay);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeReplayDefinition<AuthorityState, AuthorityEvent>(
|
||||
game: { tickRateHz: number; snapshotRateHz: number },
|
||||
definition: ReplayTransportDefinition<AuthorityState, AuthorityEvent>,
|
||||
): NormalizedReplayTransportDefinition<AuthorityState, AuthorityEvent> {
|
||||
if (!Number.isFinite(definition.historySeconds) || definition.historySeconds <= 0) {
|
||||
throw new RangeError("historySeconds must be positive");
|
||||
}
|
||||
const captureRateHz = definition.captureRateHz ?? game.snapshotRateHz;
|
||||
if (
|
||||
!Number.isInteger(captureRateHz) ||
|
||||
captureRateHz <= 0 ||
|
||||
game.tickRateHz % captureRateHz !== 0
|
||||
) {
|
||||
throw new RangeError("captureRateHz must divide tickRateHz evenly");
|
||||
}
|
||||
const maxPendingTicketsPerPlayer =
|
||||
definition.maxPendingTicketsPerPlayer ?? 2;
|
||||
if (!Number.isInteger(maxPendingTicketsPerPlayer) || maxPendingTicketsPerPlayer <= 0) {
|
||||
throw new RangeError("maxPendingTicketsPerPlayer must be a positive integer");
|
||||
}
|
||||
return {
|
||||
...definition,
|
||||
historyTicks: Math.ceil(definition.historySeconds * game.tickRateHz),
|
||||
captureEveryTicks: game.tickRateHz / captureRateHz,
|
||||
maxPendingTicketsPerPlayer,
|
||||
};
|
||||
}
|
||||
|
||||
function validPlayerId(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff;
|
||||
}
|
||||
|
||||
function validTick(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
306
packages/engine/src/server.ts
Normal file
306
packages/engine/src/server.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type {
|
||||
ClientStateContext,
|
||||
ClientStateReport,
|
||||
GameDefinition,
|
||||
InputContext,
|
||||
InputPacket,
|
||||
PlayerId,
|
||||
StateSnapshot,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export interface ServerEngineOptions {
|
||||
historyTicks?: number;
|
||||
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<Input> {
|
||||
playerId: PlayerId;
|
||||
packet: InputPacket<Input>;
|
||||
}
|
||||
|
||||
interface QueuedReport<State> {
|
||||
playerId: PlayerId;
|
||||
report: ClientStateReport<State>;
|
||||
}
|
||||
|
||||
export class AuthoritativeEngine<State, Input> {
|
||||
readonly game: GameDefinition<State, Input>;
|
||||
private readonly historyTicks: number;
|
||||
private readonly maxPastTicks: number;
|
||||
private readonly maxFutureTicks: number;
|
||||
private readonly snapshotEveryTicks: number;
|
||||
private readonly players = new Set<PlayerId>();
|
||||
private readonly lastReceivedSequence = new Map<PlayerId, number>();
|
||||
private readonly acknowledgedSequence = new Map<PlayerId, number>();
|
||||
private readonly history = new Map<number, State>();
|
||||
private queuedInputs: QueuedInput<Input>[] = [];
|
||||
private queuedReports: QueuedReport<State>[] = [];
|
||||
private state: State;
|
||||
private currentTick = 0;
|
||||
|
||||
constructor(
|
||||
game: GameDefinition<State, Input>,
|
||||
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.createInitialState();
|
||||
this.storeHistory();
|
||||
}
|
||||
|
||||
get tick(): number {
|
||||
return this.currentTick;
|
||||
}
|
||||
|
||||
get currentState(): Readonly<State> {
|
||||
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<Input>): 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<State>,
|
||||
): 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<PlayerId, number>();
|
||||
const dueInputs: QueuedInput<Input>[] = [];
|
||||
const futureInputs: QueuedInput<Input>[] = [];
|
||||
|
||||
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<State>[] = [];
|
||||
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<State> {
|
||||
return {
|
||||
tick: this.currentTick,
|
||||
serverTime,
|
||||
state: this.game.cloneState(this.state),
|
||||
};
|
||||
}
|
||||
|
||||
private inputContext(
|
||||
playerId: PlayerId,
|
||||
packet: InputPacket<Input>,
|
||||
): 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<State>,
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
474
packages/engine/src/spatial-replication.ts
Normal file
474
packages/engine/src/spatial-replication.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import {
|
||||
defineNetworkedGame,
|
||||
type DefinedNetworkedGame,
|
||||
} from "./define-networked-game.js";
|
||||
import type { ReplicationRules } from "./networked-types.js";
|
||||
import type { PlayerContext, PlayerId } from "./types.js";
|
||||
|
||||
export interface SpatialPoint {
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationEntity extends SpatialPoint {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationViewerContext extends PlayerContext {
|
||||
position: SpatialPoint;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationSource<AuthorityState> {
|
||||
category: string;
|
||||
maximumDistance: number;
|
||||
entities(
|
||||
authority: Readonly<AuthorityState>,
|
||||
): Iterable<SpatialReplicationEntity>;
|
||||
estimatedBytes:
|
||||
| number
|
||||
| ((
|
||||
entity: SpatialReplicationEntity,
|
||||
context: SpatialReplicationViewerContext,
|
||||
) => number);
|
||||
priority?:
|
||||
| number
|
||||
| ((
|
||||
entity: SpatialReplicationEntity,
|
||||
distance: number,
|
||||
context: SpatialReplicationViewerContext,
|
||||
) => number);
|
||||
required?(entity: SpatialReplicationEntity, playerId: PlayerId): boolean;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationSelection {
|
||||
readonly playerId: PlayerId;
|
||||
readonly tick: number;
|
||||
readonly position: SpatialPoint;
|
||||
readonly budgetBytes: number;
|
||||
readonly usedBytes: number;
|
||||
readonly candidateCount: number;
|
||||
readonly droppedCount: number;
|
||||
has(category: string, id: string | number): boolean;
|
||||
ids(category: string): ReadonlySet<string | number>;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> {
|
||||
cellSize: number;
|
||||
bandwidthBudgetBytesPerSecond: number;
|
||||
/** Bytes reserved for non-entity match state in every snapshot. */
|
||||
reservedBytesPerSnapshot?: number;
|
||||
viewerPosition(
|
||||
authority: Readonly<AuthorityState>,
|
||||
playerId: PlayerId,
|
||||
): SpatialPoint | null;
|
||||
sources: readonly SpatialReplicationSource<AuthorityState>[];
|
||||
projectSnapshot(
|
||||
snapshot: ClientState,
|
||||
selection: SpatialReplicationSelection,
|
||||
context: PlayerContext,
|
||||
): ClientState;
|
||||
perceive?(
|
||||
authority: Readonly<AuthorityState>,
|
||||
event: Readonly<AuthorityEvent>,
|
||||
perception: PerceptionEvent,
|
||||
selection: SpatialReplicationSelection,
|
||||
context: PlayerContext,
|
||||
): PerceptionEvent | null;
|
||||
}
|
||||
|
||||
export interface SpatialReplicationController<AuthorityState> {
|
||||
select(
|
||||
authority: Readonly<AuthorityState>,
|
||||
context: PlayerContext,
|
||||
): SpatialReplicationSelection;
|
||||
}
|
||||
|
||||
export type SpatiallyReplicatedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
> = DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> & {
|
||||
spatialReplication: SpatialReplicationController<AuthorityState>;
|
||||
};
|
||||
|
||||
interface IndexedEntity extends SpatialPoint {
|
||||
entity: SpatialReplicationEntity;
|
||||
source: SpatialReplicationSource<unknown>;
|
||||
}
|
||||
|
||||
interface Candidate extends IndexedEntity {
|
||||
distance: number;
|
||||
estimatedBytes: number;
|
||||
priority: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** A deterministic two-dimensional uniform grid used by spatial replication. */
|
||||
export class SpatialGridIndex<Value extends SpatialPoint> {
|
||||
private readonly cells = new Map<string, Value[]>();
|
||||
|
||||
constructor(readonly cellSize: number) {
|
||||
if (!Number.isFinite(cellSize) || cellSize <= 0) {
|
||||
throw new RangeError("cellSize must be a positive finite number");
|
||||
}
|
||||
}
|
||||
|
||||
insert(value: Value): void {
|
||||
if (!Number.isFinite(value.x) || !Number.isFinite(value.z)) {
|
||||
throw new RangeError("spatial coordinates must be finite");
|
||||
}
|
||||
const key = this.key(this.coordinate(value.x), this.coordinate(value.z));
|
||||
const cell = this.cells.get(key);
|
||||
if (cell) cell.push(value);
|
||||
else this.cells.set(key, [value]);
|
||||
}
|
||||
|
||||
query(position: SpatialPoint, radius: number): Value[] {
|
||||
if (!Number.isFinite(radius) || radius < 0) {
|
||||
throw new RangeError("query radius must be a non-negative finite number");
|
||||
}
|
||||
const minimumX = this.coordinate(position.x - radius);
|
||||
const maximumX = this.coordinate(position.x + radius);
|
||||
const minimumZ = this.coordinate(position.z - radius);
|
||||
const maximumZ = this.coordinate(position.z + radius);
|
||||
const radiusSquared = radius * radius;
|
||||
const values: Value[] = [];
|
||||
|
||||
for (let cellX = minimumX; cellX <= maximumX; cellX += 1) {
|
||||
for (let cellZ = minimumZ; cellZ <= maximumZ; cellZ += 1) {
|
||||
for (const value of this.cells.get(this.key(cellX, cellZ)) ?? []) {
|
||||
const dx = value.x - position.x;
|
||||
const dz = value.z - position.z;
|
||||
if (dx * dx + dz * dz <= radiusSquared) values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private coordinate(value: number): number {
|
||||
return Math.floor(value / this.cellSize);
|
||||
}
|
||||
|
||||
private key(x: number, z: number): string {
|
||||
return `${x}:${z}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds deterministic interest management and bandwidth budgeting to a game. */
|
||||
export function withSpatialReplication<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent,
|
||||
>(
|
||||
game: DefinedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
definition: SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): SpatiallyReplicatedNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> {
|
||||
validateDefinition(game.snapshotRateHz, definition);
|
||||
|
||||
let cachedAuthority: Readonly<AuthorityState> | null = null;
|
||||
let cachedTick = -1;
|
||||
let cachedIndex = new SpatialGridIndex<IndexedEntity>(definition.cellSize);
|
||||
const maximumDistance = Math.max(
|
||||
0,
|
||||
...definition.sources.map((source) => source.maximumDistance),
|
||||
);
|
||||
const budgetBytes = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
definition.bandwidthBudgetBytesPerSecond / game.snapshotRateHz -
|
||||
(definition.reservedBytesPerSnapshot ?? 0),
|
||||
),
|
||||
);
|
||||
|
||||
const controller: SpatialReplicationController<AuthorityState> = {
|
||||
select(authority, context) {
|
||||
const position = definition.viewerPosition(authority, context.playerId);
|
||||
if (!position) {
|
||||
return createSelection(
|
||||
context,
|
||||
{ x: 0, z: 0 },
|
||||
budgetBytes,
|
||||
[],
|
||||
new Map(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (authority !== cachedAuthority || context.tick !== cachedTick) {
|
||||
cachedAuthority = authority;
|
||||
cachedTick = context.tick;
|
||||
cachedIndex = buildIndex(authority, definition);
|
||||
}
|
||||
|
||||
const viewerContext: SpatialReplicationViewerContext = {
|
||||
...context,
|
||||
position,
|
||||
};
|
||||
const candidates = cachedIndex
|
||||
.query(position, maximumDistance)
|
||||
.map((indexed): Candidate | null => {
|
||||
const source = indexed.source as SpatialReplicationSource<AuthorityState>;
|
||||
const distance = Math.hypot(
|
||||
indexed.entity.x - position.x,
|
||||
indexed.entity.z - position.z,
|
||||
);
|
||||
if (distance > source.maximumDistance) return null;
|
||||
const estimatedBytes = normalizeNonNegativeInteger(
|
||||
typeof source.estimatedBytes === "function"
|
||||
? source.estimatedBytes(indexed.entity, viewerContext)
|
||||
: source.estimatedBytes,
|
||||
`${source.category}.estimatedBytes`,
|
||||
);
|
||||
const priorityValue =
|
||||
typeof source.priority === "function"
|
||||
? source.priority(indexed.entity, distance, viewerContext)
|
||||
: (source.priority ?? 0);
|
||||
return {
|
||||
...indexed,
|
||||
distance,
|
||||
estimatedBytes,
|
||||
priority: Number.isFinite(priorityValue) ? priorityValue : 0,
|
||||
required: source.required?.(indexed.entity, context.playerId) ?? false,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is Candidate => candidate !== null)
|
||||
.sort(compareCandidates);
|
||||
|
||||
const selected = new Map<string, Set<string | number>>();
|
||||
let usedBytes = 0;
|
||||
let droppedCount = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.required && usedBytes + candidate.estimatedBytes > budgetBytes) {
|
||||
droppedCount += 1;
|
||||
continue;
|
||||
}
|
||||
let ids = selected.get(candidate.source.category);
|
||||
if (!ids) {
|
||||
ids = new Set();
|
||||
selected.set(candidate.source.category, ids);
|
||||
}
|
||||
if (ids.has(candidate.entity.id)) continue;
|
||||
ids.add(candidate.entity.id);
|
||||
usedBytes += candidate.estimatedBytes;
|
||||
}
|
||||
|
||||
return createSelection(
|
||||
context,
|
||||
position,
|
||||
budgetBytes,
|
||||
candidates,
|
||||
selected,
|
||||
droppedCount,
|
||||
usedBytes,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const replication: ReplicationRules<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
> = {
|
||||
...game.replication,
|
||||
createSnapshot(authority, context) {
|
||||
const snapshot = game.replication.createSnapshot(authority, context);
|
||||
return definition.projectSnapshot(
|
||||
snapshot,
|
||||
controller.select(authority, context),
|
||||
context,
|
||||
);
|
||||
},
|
||||
...(game.replication.perceive
|
||||
? {
|
||||
perceive(authority, event, context) {
|
||||
const perception = game.replication.perceive!(
|
||||
authority,
|
||||
event,
|
||||
context,
|
||||
);
|
||||
if (perception === null) return null;
|
||||
return definition.perceive
|
||||
? definition.perceive(
|
||||
authority,
|
||||
event,
|
||||
perception,
|
||||
controller.select(authority, context),
|
||||
context,
|
||||
)
|
||||
: perception;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Spatial selections are viewer-specific even when a base game groups views.
|
||||
groupKey: (_authority, { playerId }) => playerId,
|
||||
};
|
||||
|
||||
const wrapped = defineNetworkedGame<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
Input,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>({
|
||||
tickRateHz: game.tickRateHz,
|
||||
snapshotRateHz: game.snapshotRateHz,
|
||||
server: game.server,
|
||||
client: game.client,
|
||||
validateInput: game.validateInput,
|
||||
codecs: game.codecs,
|
||||
replication,
|
||||
});
|
||||
|
||||
return Object.freeze({ ...wrapped, spatialReplication: controller });
|
||||
}
|
||||
|
||||
function buildIndex<AuthorityState, ClientState, AuthorityEvent, PerceptionEvent>(
|
||||
authority: Readonly<AuthorityState>,
|
||||
definition: SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): SpatialGridIndex<IndexedEntity> {
|
||||
const index = new SpatialGridIndex<IndexedEntity>(definition.cellSize);
|
||||
for (const source of definition.sources) {
|
||||
for (const entity of source.entities(authority)) {
|
||||
index.insert({
|
||||
x: entity.x,
|
||||
z: entity.z,
|
||||
entity,
|
||||
source: source as SpatialReplicationSource<unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function createSelection(
|
||||
context: PlayerContext,
|
||||
position: SpatialPoint,
|
||||
budgetBytes: number,
|
||||
candidates: readonly Candidate[],
|
||||
selected: ReadonlyMap<string, ReadonlySet<string | number>>,
|
||||
droppedCount: number,
|
||||
usedBytes = 0,
|
||||
): SpatialReplicationSelection {
|
||||
const empty = new Set<string | number>();
|
||||
return Object.freeze({
|
||||
playerId: context.playerId,
|
||||
tick: context.tick,
|
||||
position: { ...position },
|
||||
budgetBytes,
|
||||
usedBytes,
|
||||
candidateCount: candidates.length,
|
||||
droppedCount,
|
||||
has(category: string, id: string | number) {
|
||||
return selected.get(category)?.has(id) ?? false;
|
||||
},
|
||||
ids(category: string) {
|
||||
return selected.get(category) ?? empty;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function compareCandidates(left: Candidate, right: Candidate): number {
|
||||
return (
|
||||
Number(right.required) - Number(left.required) ||
|
||||
right.priority - left.priority ||
|
||||
left.distance - right.distance ||
|
||||
left.source.category.localeCompare(right.source.category) ||
|
||||
stableId(left.entity.id).localeCompare(stableId(right.entity.id))
|
||||
);
|
||||
}
|
||||
|
||||
function stableId(id: string | number): string {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
function validateDefinition<AuthorityState, ClientState, AuthorityEvent, PerceptionEvent>(
|
||||
snapshotRateHz: number,
|
||||
definition: SpatialReplicationDefinition<
|
||||
AuthorityState,
|
||||
ClientState,
|
||||
AuthorityEvent,
|
||||
PerceptionEvent
|
||||
>,
|
||||
): void {
|
||||
if (!Number.isFinite(definition.cellSize) || definition.cellSize <= 0) {
|
||||
throw new RangeError("cellSize must be a positive finite number");
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(definition.bandwidthBudgetBytesPerSecond) ||
|
||||
definition.bandwidthBudgetBytesPerSecond <= 0
|
||||
) {
|
||||
throw new RangeError("bandwidthBudgetBytesPerSecond must be positive");
|
||||
}
|
||||
if (
|
||||
definition.reservedBytesPerSnapshot !== undefined &&
|
||||
(!Number.isFinite(definition.reservedBytesPerSnapshot) ||
|
||||
definition.reservedBytesPerSnapshot < 0)
|
||||
) {
|
||||
throw new RangeError("reservedBytesPerSnapshot must be non-negative");
|
||||
}
|
||||
const categories = new Set<string>();
|
||||
for (const source of definition.sources) {
|
||||
if (!source.category || categories.has(source.category)) {
|
||||
throw new Error(`spatial source categories must be unique: ${source.category}`);
|
||||
}
|
||||
categories.add(source.category);
|
||||
if (!Number.isFinite(source.maximumDistance) || source.maximumDistance < 0) {
|
||||
throw new RangeError(`${source.category}.maximumDistance must be non-negative`);
|
||||
}
|
||||
if (typeof source.estimatedBytes === "number") {
|
||||
normalizeNonNegativeInteger(
|
||||
source.estimatedBytes,
|
||||
`${source.category}.estimatedBytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(snapshotRateHz) || snapshotRateHz <= 0) {
|
||||
throw new RangeError("snapshotRateHz must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new RangeError(`${name} must be non-negative and finite`);
|
||||
}
|
||||
return Math.ceil(value);
|
||||
}
|
||||
1124
packages/engine/src/time-travel.ts
Normal file
1124
packages/engine/src/time-travel.ts
Normal file
File diff suppressed because it is too large
Load Diff
80
packages/engine/src/types.ts
Normal file
80
packages/engine/src/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type PlayerId = number;
|
||||
|
||||
export interface TickContext {
|
||||
tick: number;
|
||||
deltaSeconds: number;
|
||||
}
|
||||
|
||||
export interface InputContext extends TickContext {
|
||||
playerId: PlayerId;
|
||||
sequence: number;
|
||||
targetTick: number;
|
||||
/** Client simulation tick whose world presentation produced this input. */
|
||||
observedTick: number;
|
||||
}
|
||||
|
||||
export interface PlayerContext {
|
||||
playerId: PlayerId;
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export interface ClientStateContext extends PlayerContext {}
|
||||
|
||||
export interface GameDefinition<State, Input> {
|
||||
tickRateHz: number;
|
||||
snapshotRateHz: number;
|
||||
createInitialState(): State;
|
||||
cloneState(state: State): State;
|
||||
step(state: State, context: TickContext): void;
|
||||
applyInput(state: State, input: Input, context: InputContext): void;
|
||||
validateInput(input: Input, context: InputContext): boolean;
|
||||
validateState?(state: State, context: TickContext): boolean;
|
||||
validateClientState?(
|
||||
authoritative: State,
|
||||
candidate: State,
|
||||
context: ClientStateContext,
|
||||
): boolean;
|
||||
addPlayer?(state: State, context: PlayerContext): void;
|
||||
removePlayer?(state: State, context: PlayerContext): void;
|
||||
}
|
||||
|
||||
export interface InputPacket<Input> {
|
||||
sequence: number;
|
||||
targetTick: number;
|
||||
/** Optional for backwards compatibility; servers fall back to targetTick. */
|
||||
observedTick?: number;
|
||||
input: Input;
|
||||
}
|
||||
|
||||
export interface StateSnapshot<State> {
|
||||
tick: number;
|
||||
serverTime: number;
|
||||
state: State;
|
||||
}
|
||||
|
||||
export interface ClientStateReport<State> {
|
||||
tick: number;
|
||||
state: State;
|
||||
}
|
||||
|
||||
export interface PingPacket {
|
||||
id: number;
|
||||
clientSentAt: number;
|
||||
}
|
||||
|
||||
export interface PongPacket extends PingPacket {
|
||||
serverReceivedAt: number;
|
||||
serverSentAt: number;
|
||||
}
|
||||
|
||||
export interface BinaryCodec<Value> {
|
||||
encode(value: Value): Uint8Array;
|
||||
decode(payload: Uint8Array): Value;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
playerId: PlayerId;
|
||||
tick: number;
|
||||
valid: boolean;
|
||||
reason?: "mismatch" | "outside-history" | "too-far-ahead";
|
||||
}
|
||||
Reference in New Issue
Block a user