deploy Syncer to a2acloud Kubernetes
Some checks failed
build / image (push) Failing after 28s

This commit is contained in:
Syncer Deploy
2026-08-28 10:46:42 -03:00
commit e93c1b026f
72 changed files with 17680 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
{
"name": "@syncer/engine",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^7.0.2"
}
}

View 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}`,
);
}
}
}

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

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

View 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 }
: {}),
},
});
}

View 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",
);
}
}

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

View 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";

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

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

View 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,
};
}
}

View 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}`,
);
}
}
}

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View 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";
}

View File

@@ -0,0 +1,874 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
FixedStepClock,
NetworkClock,
ReplayDivergenceError,
SpatialGridIndex,
createBinaryProtocol,
createJsonCodec,
deterministicHash,
defineGame,
defineMultiplayerGame,
defineNetworkedGame,
withLagCompensation,
withReplayTransport,
withSpatialReplication,
withTimeTravel,
} from "../dist/index.js";
test("spatial replication indexes, prioritizes, budgets, and projects per viewer", () => {
const grid = new SpatialGridIndex(10);
grid.insert({ id: "negative", x: -4, z: -7 });
grid.insert({ id: "near", x: 6, z: 2 });
grid.insert({ id: "far", x: 40, z: 40 });
assert.deepEqual(
grid.query({ x: 0, z: 0 }, 10).map(({ id }) => id).sort(),
["near", "negative"],
);
const codec = createJsonCodec();
const base = defineMultiplayerGame({
clock: { ticksPerSecond: 10, snapshotsPerSecond: 10 },
authority: {
createInitialState: () => ({
entities: [
{ id: 1, x: 0, z: 0 },
{ id: 2, x: 10, z: 0 },
{ id: 3, x: 20, z: 0 },
{ id: 4, x: 200, z: 0 },
],
}),
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })) }),
applyInput() {},
step() {},
},
prediction: {
createInitialState: () => ({ entities: [], events: [] }),
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })), events: [...state.events] }),
applyInput() {},
step() {},
applyEvent(state, event) {
state.events.push(event);
},
},
visibility: {
createSnapshot: (authority) => ({
entities: authority.entities.map((entity) => ({ ...entity })),
events: [],
}),
perceive: (_authority, event) => event,
},
input: { validate: () => true },
encoding: { input: codec, clientState: codec, perception: codec },
});
const game = withSpatialReplication(base, {
cellSize: 16,
bandwidthBudgetBytesPerSecond: 1_000,
viewerPosition: (authority, playerId) => authority.entities.find(({ id }) => id === playerId) ?? null,
sources: [
{
category: "entities",
maximumDistance: 100,
entities: (authority) => authority.entities,
estimatedBytes: 40,
required: (entity, playerId) => entity.id === playerId,
},
],
projectSnapshot(snapshot, selection) {
return {
...snapshot,
entities: snapshot.entities.filter((entity) => selection.has("entities", entity.id)),
};
},
perceive(_authority, event, perception, selection) {
return selection.has("entities", event.sourceId) ? perception : null;
},
});
const server = game.createServer();
server.addPlayer(1);
assert.deepEqual(
server.createSnapshot(1, 0).state.entities.map(({ id }) => id),
[1, 2],
);
const selection = game.spatialReplication.select(server.currentState, {
playerId: 1,
tick: server.tick,
});
assert.equal(selection.budgetBytes, 100);
assert.equal(selection.usedBytes, 80);
assert.equal(selection.candidateCount, 3);
assert.equal(selection.droppedCount, 1);
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 2 }]), [{ sourceId: 2 }]);
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 3 }]), []);
});
test("the developer-facing API compiles named sections into a networked game", () => {
const codec = createJsonCodec();
const game = defineMultiplayerGame({
clock: { ticksPerSecond: 12, snapshotsPerSecond: 4 },
authority: {
createInitialState: () => ({ value: 0 }),
cloneState: (state) => ({ ...state }),
applyInput(state, input) {
state.value += input.amount;
},
step() {},
},
prediction: {
createInitialState: () => ({ value: 0 }),
cloneState: (state) => ({ ...state }),
applyInput(state, input) {
state.value += input.amount;
},
step() {},
},
visibility: {
createSnapshot: (authority) => ({ value: authority.value }),
},
input: {
validate: (input) => Number.isFinite(input.amount),
},
encoding: {
input: codec,
clientState: codec,
},
});
assert.equal(game.tickRateHz, 12);
assert.equal(game.snapshotRateHz, 4);
const server = game.createServer();
server.addPlayer(1);
assert.equal(
server.submitInput(1, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { amount: 3 },
}).accepted,
true,
);
server.step();
assert.equal(server.currentState.value, 3);
assert.deepEqual(codec.decode(codec.encode({ unicode: "flux ⚡" })), {
unicode: "flux ⚡",
});
});
const testGame = defineGame({
tickRateHz: 10,
snapshotRateHz: 5,
createInitialState: () => ({ position: 0, velocity: 0 }),
cloneState: (state) => ({ ...state }),
validateInput: (input) =>
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
applyInput: (state, input) => {
state.velocity = input.axis;
},
step: (state, { deltaSeconds }) => {
state.position += state.velocity * deltaSeconds;
},
validateState: (state) =>
Number.isFinite(state.position) && Number.isFinite(state.velocity),
validateClientState: (authoritative, candidate) =>
Math.abs(authoritative.position - candidate.position) < 0.000_001,
});
test("defineGame creates matching authoritative and predicted runtimes", () => {
const server = testGame.createServer();
const client = testGame.createClient();
server.addPlayer(7);
client.initialize(7, server.createSnapshot(0));
const packet = client.createInput({ axis: 1 }, 1);
assert.deepEqual(server.submitInput(7, packet), { accepted: true });
client.step();
const result = server.step();
assert.equal(result.snapshotDue, false);
assert.deepEqual(result.acknowledgements, [
{ playerId: 7, sequence: packet.sequence },
]);
client.acknowledge(packet.sequence);
client.reconcile(server.createSnapshot(100));
assert.equal(client.currentState.position, server.currentState.position);
const valid = server.submitStateReport(7, client.createStateReport());
assert.equal(valid?.valid, true);
const invalid = server.submitStateReport(7, {
tick: server.tick,
state: { position: 99, velocity: 0 },
});
assert.equal(invalid?.valid, false);
assert.deepEqual(
server.submitInput(7, {
sequence: packet.sequence + 1,
targetTick: server.tick + 1,
input: { axis: 2 },
}),
{ accepted: false, reason: "invalid" },
);
});
test("fixed-step clocks cap catch-up work", () => {
const clock = new FixedStepClock({ rateHz: 10, maxCatchUpSteps: 3 });
let steps = 0;
assert.equal(clock.advance(0, () => steps++), 0);
assert.equal(clock.advance(100, () => steps++), 1);
assert.equal(clock.advance(1_000, () => steps++), 3);
assert.equal(steps, 4);
});
test("network clock estimates RTT, offset, and input lead", () => {
const clock = new NetworkClock();
const ping = clock.createPing(100);
const stats = clock.receivePong(
{
...ping,
serverReceivedAt: 160,
serverSentAt: 162,
},
202,
);
assert.equal(stats.roundTripTime, 100);
assert.equal(stats.clockOffset, 10);
assert.equal(clock.toServerTime(300), 310);
assert.equal(clock.recommendedInputLeadTicks(60), 4);
});
test("binary protocol round-trips generic inputs, state, and control frames", () => {
const numberCodec = {
encode(value) {
const payload = new Uint8Array(8);
new DataView(payload.buffer).setFloat64(0, value, true);
return payload;
},
decode(payload) {
return new DataView(
payload.buffer,
payload.byteOffset,
payload.byteLength,
).getFloat64(0, true);
},
};
const protocol = createBinaryProtocol({
input: numberCodec,
state: numberCodec,
event: numberCodec,
});
const input = {
kind: "input",
packet: {
sequence: 4,
targetTick: 20,
observedTick: 16,
input: 0.75,
},
};
assert.deepEqual(protocol.decodeClient(protocol.encodeClient(input)), input);
const welcome = {
kind: "welcome",
playerId: 9,
snapshot: { tick: 18, serverTime: 42.5, state: 3.25 },
};
assert.deepEqual(protocol.decodeServer(protocol.encodeServer(welcome)), welcome);
const rejection = { kind: "reject-input", sequence: 44 };
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(rejection)),
rejection,
);
const replayStart = {
kind: "replay-start",
ticketId: 12,
perspectiveId: 9,
fromTick: 100,
toTick: 160,
frameCount: 2,
playbackRate: 0.75,
};
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(replayStart)),
replayStart,
);
const replayFrame = {
kind: "replay-frame",
ticketId: 12,
tick: 120,
state: 3.5,
events: [4.5, 5.5],
};
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(replayFrame)),
replayFrame,
);
const truncatedReplayFrame = protocol.encodeServer(replayFrame).slice(0, -1);
assert.throws(() => protocol.decodeServer(truncatedReplayFrame), RangeError);
const replayEnd = { kind: "replay-end", ticketId: 12 };
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(replayEnd)),
replayEnd,
);
});
test("networked games keep authority private and derive viewer perceptions", () => {
const jsonCodec = {
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
};
const privateGame = defineNetworkedGame({
tickRateHz: 10,
snapshotRateHz: 5,
server: {
createInitialState: () => ({
publicValues: new Map(),
secrets: new Map(),
}),
cloneState: (state) => ({
publicValues: new Map(state.publicValues),
secrets: new Map(state.secrets),
}),
addPlayer(state, { playerId }) {
state.publicValues.set(playerId, 0);
state.secrets.set(playerId, `secret-${playerId}`);
},
applyInput(state, input, { playerId, emit }) {
state.publicValues.set(playerId, input);
emit({ source: playerId, privateValue: state.secrets.get(playerId) });
},
step() {},
},
client: {
createInitialState: () => ({ ownValue: 0, cues: [] }),
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
applyInput(state, input) {
state.ownValue = input;
},
step() {},
applyEvent(state, event) {
state.cues.push(event);
},
},
replication: {
createSnapshot(authority, { playerId }) {
return {
ownValue: authority.publicValues.get(playerId) ?? 0,
cues: [],
};
},
validateClientState(authority, candidate, { playerId }) {
return candidate.ownValue === authority.publicValues.get(playerId);
},
perceive(_authority, event, { playerId }) {
return event.source === playerId ? null : "heard-an-event";
},
},
validateInput: (input) => Number.isFinite(input),
codecs: { input: jsonCodec, state: jsonCodec, event: jsonCodec },
});
const server = privateGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const snapshot = server.createSnapshot(1, 0);
assert.deepEqual(snapshot.state, { ownValue: 0, cues: [] });
assert.equal("secrets" in snapshot.state, false);
assert.equal(
server.submitInput(2, { sequence: 1, targetTick: 1, input: 5 }).accepted,
true,
);
const result = server.step();
assert.deepEqual(server.createPerceptions(1, result.events), [
"heard-an-event",
]);
assert.deepEqual(server.createPerceptions(2, result.events), []);
const client = privateGame.createClient();
client.initialize(1, snapshot);
client.receiveEvent("heard-an-event", result.tick);
assert.deepEqual(client.currentState.cues, ["heard-an-event"]);
const eventFrame = privateGame.protocol.encodeServer({
kind: "event",
tick: result.tick,
event: "heard-an-event",
});
assert.deepEqual(privateGame.protocol.decodeServer(eventFrame), {
kind: "event",
tick: result.tick,
event: "heard-an-event",
});
});
const lagJsonCodec = {
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
};
const lagBaseGame = defineNetworkedGame({
tickRateHz: 10,
snapshotRateHz: 5,
server: {
createInitialState: () => ({ positions: {}, resolutions: [] }),
cloneState: (state) => ({
positions: { ...state.positions },
resolutions: state.resolutions.map((entry) => ({ ...entry })),
}),
addPlayer(state, { playerId }) {
state.positions[playerId] = 0;
},
applyInput(state, input, { playerId }) {
if (input.moveTo !== undefined) state.positions[playerId] = input.moveTo;
},
step() {},
},
client: {
createInitialState: () => ({ positions: {}, resolutions: [] }),
cloneState: (state) => structuredClone(state),
applyInput() {},
step() {},
},
replication: {
createSnapshot: (state) => structuredClone(state),
},
validateInput: (input) =>
(input.moveTo === undefined || Number.isFinite(input.moveTo)) &&
(input.action === undefined ||
["rewind", "current", "projectile", "strict"].includes(input.action)),
codecs: { input: lagJsonCodec, state: lagJsonCodec, event: lagJsonCodec },
});
const lagPolicyGame = withLagCompensation(lagBaseGame, {
historySeconds: 2,
captureState: (state) => ({ positions: { ...state.positions } }),
cloneHistoricalState: (state) => ({ positions: { ...state.positions } }),
classifyAction: (input) =>
input.action ? { type: input.action, targetId: input.targetId ?? 2 } : null,
cloneAction: (action) => ({ ...action }),
actions: {
rewind: {
mode: "rewind",
maximumRewindMs: 1_000,
resolve: recordLagResolution,
},
current: {
mode: "current",
resolve: recordLagResolution,
},
projectile: {
mode: "fast-forward",
maximumRewindMs: 1_000,
resolve: recordLagResolution,
},
strict: {
mode: "rewind",
maximumRewindMs: 100,
outOfWindow: "reject",
resolve: recordLagResolution,
},
},
});
const lagReplayGame = withTimeTravel(lagPolicyGame, {
createSeed: () => 0,
hashState: deterministicHash,
checkpointIntervalTicks: 2,
verificationIntervalTicks: 1,
});
function recordLagResolution(context) {
context.currentState.resolutions.push({
type: context.action.type,
requestedTick: context.requestedTick,
resolvedTick: context.resolvedTick,
currentTick: context.currentTick,
rewindTicks: context.rewindTicks,
catchUpTicks: context.catchUpTicks,
historicalPosition:
context.historicalState.positions[context.action.targetId],
currentPosition: context.currentState.positions[context.action.targetId],
});
}
test("lag-compensation HOF applies an independent policy per action", () => {
const server = lagPolicyGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
server.submitInput(2, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { moveTo: 5 },
});
server.step();
server.step();
server.submitInput(1, {
sequence: 1,
targetTick: 3,
observedTick: 0,
input: { action: "rewind", targetId: 2 },
});
server.step();
server.submitInput(1, {
sequence: 2,
targetTick: 4,
observedTick: 0,
input: { action: "current", targetId: 2 },
});
server.step();
server.submitInput(1, {
sequence: 3,
targetTick: 5,
observedTick: 2,
input: { action: "projectile", targetId: 2 },
});
server.step();
server.submitInput(1, {
sequence: 4,
targetTick: 6,
observedTick: 0,
input: { action: "strict", targetId: 2 },
});
server.step();
assert.equal(server.currentState.positions[2], 5);
assert.deepEqual(server.currentState.resolutions, [
{
type: "rewind",
requestedTick: 0,
resolvedTick: 0,
currentTick: 3,
rewindTicks: 3,
catchUpTicks: 0,
historicalPosition: 0,
currentPosition: 5,
},
{
type: "current",
requestedTick: 0,
resolvedTick: 4,
currentTick: 4,
rewindTicks: 0,
catchUpTicks: 0,
historicalPosition: 5,
currentPosition: 5,
},
{
type: "projectile",
requestedTick: 2,
resolvedTick: 2,
currentTick: 5,
rewindTicks: 3,
catchUpTicks: 3,
historicalPosition: 5,
currentPosition: 5,
},
]);
});
test("lag-compensated actions replay deterministically through checkpoints", () => {
const server = lagReplayGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
server.submitInput(2, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { moveTo: 5 },
});
server.step();
server.step();
server.submitInput(1, {
sequence: 1,
targetTick: 3,
observedTick: 0,
input: { action: "rewind", targetId: 2 },
});
for (let tick = 2; tick < 6; tick += 1) server.step();
const replay = lagReplayGame.createReplay(server.exportRecording());
assert.deepEqual(replay.seek(6).state, server.currentState);
assert.equal(replay.currentState.resolutions[0].historicalPosition, 0);
});
const replayCodec = {
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
};
const replayDefinition = defineNetworkedGame({
tickRateHz: 10,
snapshotRateHz: 5,
server: {
createInitialState: () => ({
seed: 0,
secret: "uninitialized",
positions: new Map(),
velocities: new Map(),
}),
cloneState: (state) => ({
...state,
positions: new Map(state.positions),
velocities: new Map(state.velocities),
}),
addPlayer(state, { playerId, emit }) {
state.positions.set(playerId, state.seed);
state.velocities.set(playerId, 0);
emit({ type: "joined", playerId });
},
removePlayer(state, { playerId }) {
state.positions.delete(playerId);
state.velocities.delete(playerId);
},
applyInput(state, input, { playerId, emit }) {
state.velocities.set(playerId, input.axis);
emit({ type: "moved", playerId });
},
step(state, { deltaSeconds }) {
for (const [playerId, velocity] of state.velocities) {
state.positions.set(
playerId,
(state.positions.get(playerId) ?? 0) + velocity * deltaSeconds,
);
}
},
},
client: {
createInitialState: () => ({ ownPosition: 0, cues: [] }),
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
applyInput(state, input) {
state.ownPosition += input.axis / 10;
},
step() {},
applyEvent(state, event) {
state.cues.push(event);
},
},
replication: {
createSnapshot(authority, { playerId }) {
return {
ownPosition: authority.positions.get(playerId) ?? 0,
cues: [],
};
},
perceive(_authority, event, { playerId }) {
return event.playerId === playerId ? null : `heard-${event.type}`;
},
},
validateInput: (input) =>
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
codecs: {
input: replayCodec,
state: replayCodec,
event: replayCodec,
},
});
const replayGame = withTimeTravel(replayDefinition, {
createSeed: () => 1,
initializeState(state, seed) {
state.seed = seed;
state.secret = `secret-${seed}`;
},
hashState: deterministicHash,
checkpointIntervalTicks: 2,
verificationIntervalTicks: 1,
});
const replayTransportGame = withReplayTransport(replayGame, {
historySeconds: 0.6,
captureRateHz: 5,
createTickets(event, { tick }) {
return event.type === "joined" && event.playerId === 2
? {
requesterId: 1,
perspectiveId: 2,
fromTick: 0,
toTick: tick,
}
: null;
},
authorizeReplay: ({ requesterId, perspectiveId }) =>
requesterId === 1 && perspectiveId === 2,
});
test("time-travel HOF records, seeks, verifies, filters, and branches", () => {
const server = replayGame.createServer({ replaySeed: 7 });
server.addPlayer(1);
server.addPlayer(2);
assert.equal(
server.submitInput(1, {
sequence: 1,
targetTick: 1,
input: { axis: 1 },
}).accepted,
true,
);
assert.equal(
server.submitInput(2, {
sequence: 1,
targetTick: 1,
input: { axis: -0.5 },
}).accepted,
true,
);
for (let tick = 0; tick < 3; tick += 1) server.step();
assert.equal(
server.submitInput(1, {
sequence: 2,
targetTick: 5,
input: { axis: 0.25 },
}).accepted,
true,
);
for (let tick = 3; tick < 6; tick += 1) server.step();
const recording = server.exportRecording();
assert.equal(recording.seed, 7);
assert.equal(recording.durationTicks, 6);
assert.deepEqual(
recording.checkpoints.map(({ tick }) => tick),
[0, 2, 4, 6],
);
const replay = replayGame.createReplay(recording);
const finalFrame = replay.seek(6);
assert.deepEqual(finalFrame.state, server.currentState);
assert.equal(finalFrame.hash, deterministicHash(server.currentState));
const privateView = replay.viewAs(1, 123);
assert.equal(privateView.tick, 6);
assert.equal("secret" in privateView.state, false);
assert.deepEqual(Object.keys(privateView.state).sort(), ["cues", "ownPosition"]);
replay.seek(1);
assert.deepEqual(replay.perceptionsAs(1), ["heard-joined", "heard-moved"]);
const rewindPosition = replay.currentState.positions.get(1);
replay.seek(6);
assert.notEqual(replay.currentState.positions.get(1), rewindPosition);
const branch = replay.branch(2);
assert.equal(
branch.submitInput(1, {
sequence: 2,
targetTick: 3,
input: { axis: -1 },
}).accepted,
true,
);
while (branch.tick < 6) branch.step();
assert.notEqual(
deterministicHash(branch.currentState),
deterministicHash(server.currentState),
);
const branchReplay = replayGame.createReplay(branch.exportRecording());
assert.deepEqual(branchReplay.seek(6).state, branch.currentState);
assert.deepEqual(replay.seek(6).state, server.currentState);
});
test("time-travel replay pinpoints deterministic divergence", () => {
const server = replayGame.createServer({ replaySeed: 4 });
server.addPlayer(1);
server.submitInput(1, {
sequence: 1,
targetTick: 1,
input: { axis: 1 },
});
for (let tick = 0; tick < 3; tick += 1) server.step();
const recording = server.exportRecording();
const corrupted = {
...recording,
verifications: recording.verifications.map((entry) =>
entry.tick === 3 ? { ...entry, hash: "corrupted" } : entry,
),
};
const replay = replayGame.createReplay(corrupted);
assert.throws(() => replay.seek(3), ReplayDivergenceError);
});
test("rolling authority recordings stay bounded and remain seekable", () => {
const server = replayGame.createServer({
replaySeed: 8,
recordingHistoryTicks: 4,
});
server.addPlayer(1);
for (let tick = 0; tick < 10; tick += 1) server.step();
const recording = server.exportRecording();
assert.equal(recording.startTick, 6);
assert.equal(recording.durationTicks, 10);
assert.ok(recording.verifications.every(({ tick }) => tick >= 6));
assert.ok(recording.checkpoints.every(({ tick }) => tick >= 6));
const replay = replayGame.createReplay(recording);
assert.equal(replay.startTick, 6);
assert.deepEqual(replay.seek(10).state, server.currentState);
assert.throws(() => replay.seek(5), RangeError);
});
test("replay transport issues only authorized projected frames from a bounded ring", () => {
const server = replayTransportGame.createServer({ replaySeed: 11 });
server.addPlayer(1);
server.addPlayer(2);
for (let tick = 0; tick < 12; tick += 1) server.step();
const automaticTickets = server.drainReplayTickets(1);
assert.equal(automaticTickets.length, 1);
assert.equal(automaticTickets[0].perspectiveId, 2);
assert.equal(
server.issueReplay({
requesterId: 1,
perspectiveId: 1,
fromTick: 0,
toTick: 12,
}),
null,
);
const ticket = server.issueReplay({
requesterId: 1,
perspectiveId: 2,
fromTick: 0,
toTick: 12,
playbackRate: 0.5,
});
assert.ok(ticket);
assert.equal(ticket.requesterId, 1);
assert.equal(ticket.perspectiveId, 2);
assert.equal(ticket.fromTick, 6);
assert.equal(ticket.toTick, 12);
assert.equal(ticket.playbackRate, 0.5);
assert.ok(ticket.frames.length > 1);
assert.ok(
ticket.frames.every(
({ state }) =>
!Object.hasOwn(state, "secret") &&
Object.keys(state).sort().join(",") === "cues,ownPosition",
),
);
assert.deepEqual(server.drainReplayTickets(1), [ticket]);
assert.deepEqual(server.drainReplayTickets(1), []);
assert.equal(server.exportRecording().startTick, 6);
});

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,28 @@
{
"name": "@syncer/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"dependencies": {
"@syncer/engine": "0.0.0"
},
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^7.0.2"
}
}

View File

@@ -0,0 +1,231 @@
export interface ArenaBlock {
id: string;
x: number;
z: number;
width: number;
depth: number;
height: number;
style: "reactor" | "wall" | "crate" | "column";
}
export interface ArenaSpawn {
x: number;
z: number;
yaw: number;
}
export interface ArenaPoint {
x: number;
z: number;
}
export const ARENA_HALF_SIZE = 18;
export const PLAYER_RADIUS = 0.44;
export const PLAYER_EYE_HEIGHT = 1.58;
export const PLAYER_HEIGHT = 1.8;
export const ARENA_BLOCKS: readonly ArenaBlock[] = [
{ id: "reactor", x: 0, z: 0, width: 4.2, depth: 4.2, height: 4.8, style: "reactor" },
{ id: "north-spine", x: 0, z: -10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
{ id: "south-spine", x: 0, z: 10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
{ id: "west-spine", x: -10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
{ id: "east-spine", x: 10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
{ id: "nw-cover-a", x: -7, z: -7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
{ id: "nw-cover-b", x: -7, z: -4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
{ id: "se-cover-a", x: 7, z: 7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
{ id: "se-cover-b", x: 7, z: 4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
{ id: "ne-cover-a", x: 7, z: -7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
{ id: "sw-cover-a", x: -7, z: 7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
{ id: "northwest-column", x: -14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
{ id: "northeast-column", x: 14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
{ id: "southwest-column", x: -14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
{ id: "southeast-column", x: 14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
] as const;
export const ARENA_SPAWNS: readonly ArenaSpawn[] = [
{ x: -14.5, z: -9.5, yaw: 2.35 },
{ x: 14.5, z: 9.5, yaw: -0.78 },
{ x: 14.5, z: -9.5, yaw: -2.35 },
{ x: -14.5, z: 9.5, yaw: 0.78 },
{ x: 0, z: -15, yaw: Math.PI },
{ x: 0, z: 15, yaw: 0 },
{ x: -15, z: 0, yaw: Math.PI / 2 },
{ x: 15, z: 0, yaw: -Math.PI / 2 },
] as const;
export const BOT_PATROL_POINTS: readonly ArenaPoint[] = [
{ x: -13, z: -8 },
{ x: -5.5, z: -12.5 },
{ x: 5.5, z: -12.5 },
{ x: 13, z: -8 },
{ x: 12.5, z: 4 },
{ x: 8, z: 12.5 },
{ x: -3.5, z: 13.5 },
{ x: -12.5, z: 8 },
{ x: -13.5, z: -3.5 },
{ x: -5.8, z: -4.8 },
{ x: 5.8, z: 4.8 },
{ x: 5.5, z: -5.5 },
] as const;
export function hasLineOfSight(
fromX: number,
fromZ: number,
toX: number,
toZ: number,
): boolean {
for (const block of ARENA_BLOCKS) {
if (segmentIntersectsExpandedBlock(fromX, fromZ, toX, toZ, block, 0.08)) {
return false;
}
}
return true;
}
export function collidesWithArena(x: number, z: number, radius = PLAYER_RADIUS): boolean {
if (
x - radius < -ARENA_HALF_SIZE ||
x + radius > ARENA_HALF_SIZE ||
z - radius < -ARENA_HALF_SIZE ||
z + radius > ARENA_HALF_SIZE
) {
return true;
}
return ARENA_BLOCKS.some((block) =>
circleIntersectsBlock(x, z, radius, block),
);
}
export function rayBlockDistance(
originX: number,
originY: number,
originZ: number,
directionX: number,
directionY: number,
directionZ: number,
maximumDistance: number,
): number | null {
let nearest: number | null = null;
for (const block of ARENA_BLOCKS) {
const distance = rayAabbDistance(
originX,
originY,
originZ,
directionX,
directionY,
directionZ,
block.x - block.width / 2,
0,
block.z - block.depth / 2,
block.x + block.width / 2,
block.height,
block.z + block.depth / 2,
);
if (
distance !== null &&
distance >= 0 &&
distance <= maximumDistance &&
(nearest === null || distance < nearest)
) {
nearest = distance;
}
}
return nearest;
}
function circleIntersectsBlock(
x: number,
z: number,
radius: number,
block: ArenaBlock,
): boolean {
const halfWidth = block.width / 2;
const halfDepth = block.depth / 2;
const nearestX = Math.max(block.x - halfWidth, Math.min(x, block.x + halfWidth));
const nearestZ = Math.max(block.z - halfDepth, Math.min(z, block.z + halfDepth));
const dx = x - nearestX;
const dz = z - nearestZ;
return dx * dx + dz * dz < radius * radius;
}
function segmentIntersectsExpandedBlock(
fromX: number,
fromZ: number,
toX: number,
toZ: number,
block: ArenaBlock,
padding: number,
): boolean {
const directionX = toX - fromX;
const directionZ = toZ - fromZ;
const distance = Math.hypot(directionX, directionZ);
if (distance < 0.0001) {
return false;
}
const hit = rayAabbDistance(
fromX,
1.2,
fromZ,
directionX / distance,
0,
directionZ / distance,
block.x - block.width / 2 - padding,
0,
block.z - block.depth / 2 - padding,
block.x + block.width / 2 + padding,
block.height,
block.z + block.depth / 2 + padding,
);
return hit !== null && hit > 0.05 && hit < distance - 0.05;
}
function rayAabbDistance(
originX: number,
originY: number,
originZ: number,
directionX: number,
directionY: number,
directionZ: number,
minimumX: number,
minimumY: number,
minimumZ: number,
maximumX: number,
maximumY: number,
maximumZ: number,
): number | null {
let minimumDistance = -Infinity;
let maximumDistance = Infinity;
for (const [origin, direction, minimum, maximum] of [
[originX, directionX, minimumX, maximumX],
[originY, directionY, minimumY, maximumY],
[originZ, directionZ, minimumZ, maximumZ],
] as const) {
if (Math.abs(direction) < 0.000001) {
if (origin < minimum || origin > maximum) {
return null;
}
continue;
}
const inverse = 1 / direction;
let near = (minimum - origin) * inverse;
let far = (maximum - origin) * inverse;
if (near > far) {
[near, far] = [far, near];
}
minimumDistance = Math.max(minimumDistance, near);
maximumDistance = Math.min(maximumDistance, far);
if (maximumDistance < minimumDistance) {
return null;
}
}
return maximumDistance >= 0 ? Math.max(0, minimumDistance) : null;
}

View File

@@ -0,0 +1,364 @@
import {
createJsonCodec,
defineMultiplayerGame,
} from "@syncer/engine";
import type {
FluxAuthorityEvent,
FluxAuthorityPlayer,
FluxAuthorityState,
FluxClientState,
FluxGameContract,
FluxInput,
FluxPerception,
FluxPlayerView,
FluxTeam,
} from "./flux-types.js";
export const FLUX_SOCKET_PATH = "/ws/flux";
export const FLUX_TICK_RATE = 30;
export const FLUX_SNAPSHOT_RATE = 10;
export const FLUX_ROUND_RESET_TICKS = 60;
export const FLUX_BOT_IDS = [20_001, 20_002] as const;
const maximumEnergy = 100;
const energyDrainPerSecond = 31;
const energyRecoveryPerSecond = 20;
const coreAcceleration = 0.92;
const coreDragPerSecond = 0.86;
const eventLifetimeTicks = FLUX_TICK_RATE * 4;
const inputCodec = createJsonCodec<FluxInput>();
const stateCodec = createJsonCodec<FluxClientState>();
const perceptionCodec = createJsonCodec<FluxPerception>();
/**
* A complete second game built through the public authoring API. Flux Relay is
* a team tug-of-war: hold thrust, manage private energy, and pull the shared
* core through your team's gate.
*/
export const fluxGame = defineMultiplayerGame<FluxGameContract>({
clock: {
ticksPerSecond: FLUX_TICK_RATE,
snapshotsPerSecond: FLUX_SNAPSHOT_RATE,
},
authority: {
createInitialState: createAuthorityState,
cloneState: cloneAuthorityState,
addPlayer(state, { playerId }) {
state.players.push(createPlayer(playerId, leastPopulatedTeam(state), false));
sortPlayers(state.players);
},
removePlayer(state, { playerId }) {
state.players = state.players.filter((player) => player.id !== playerId);
},
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (!player || player.bot) return;
player.thrust = input.thrust && !player.overheated;
},
step(state, { tick, deltaSeconds, emit }) {
state.elapsedTicks += 1;
if (state.resetTicks > 0) {
state.resetTicks -= 1;
state.core *= 0.88;
state.velocity = 0;
if (state.resetTicks === 0) state.core = 0;
updatePlayers(state, tick, deltaSeconds, emit, false);
return;
}
updatePlayers(state, tick, deltaSeconds, emit, true);
const cyanForce = activePlayerCount(state, "cyan");
const orangeForce = activePlayerCount(state, "orange");
const signedForce = orangeForce - cyanForce;
state.velocity += signedForce * coreAcceleration * deltaSeconds;
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
state.core += state.velocity * deltaSeconds;
if (Math.abs(state.core) >= 1) {
const team: FluxTeam = state.core < 0 ? "cyan" : "orange";
state.core = team === "cyan" ? -1 : 1;
state.velocity = 0;
state.resetTicks = FLUX_ROUND_RESET_TICKS;
state.round += 1;
if (team === "cyan") state.cyanScore += 1;
else state.orangeScore += 1;
emit({
id: state.nextEventId++,
type: "round-won",
team,
round: state.round,
});
}
},
validateState(state) {
return (
Number.isFinite(state.core) &&
Number.isFinite(state.velocity) &&
Math.abs(state.core) <= 1.000_001 &&
state.players.every(
(player) =>
Number.isFinite(player.energy) &&
player.energy >= 0 &&
player.energy <= maximumEnergy,
)
);
},
},
prediction: {
createInitialState: createClientState,
cloneState: cloneClientState,
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (player && player.energy !== null) {
player.thrust = input.thrust && player.energy > 0;
}
},
step(state, { tick, deltaSeconds }) {
state.elapsedTicks += 1;
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - eventLifetimeTicks,
);
const ownPlayer = state.players.find((player) => player.energy !== null);
if (ownPlayer) updateVisibleEnergy(ownPlayer, deltaSeconds);
if (state.resetTicks > 0) {
state.resetTicks -= 1;
state.core *= 0.88;
state.velocity = 0;
if (state.resetTicks === 0) state.core = 0;
return;
}
const cyanForce = visibleActiveCount(state, "cyan");
const orangeForce = visibleActiveCount(state, "orange");
state.velocity += (orangeForce - cyanForce) * coreAcceleration * deltaSeconds;
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
state.core = clamp(state.core + state.velocity * deltaSeconds, -1, 1);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const merged = cloneClientState(snapshot);
merged.events = predicted.events
.filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks)
.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
}));
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
},
validateState(state) {
const ownEnergy = state.players.find((player) => player.energy !== null)?.energy;
return (
Number.isFinite(state.core) &&
Math.abs(state.core) <= 1.000_001 &&
(ownEnergy === undefined ||
(ownEnergy !== null && ownEnergy >= 0 && ownEnergy <= maximumEnergy))
);
},
},
visibility: {
createSnapshot(authority, { playerId }) {
return {
core: authority.core,
velocity: authority.velocity,
round: authority.round,
cyanScore: authority.cyanScore,
orangeScore: authority.orangeScore,
resetTicks: authority.resetTicks,
elapsedTicks: authority.elapsedTicks,
players: authority.players.map((player) => ({
id: player.id,
team: player.team,
bot: player.bot,
thrust: player.thrust,
energy: player.id === playerId ? player.energy : null,
})),
events: [],
};
},
validateClientState(authority, candidate, { playerId }) {
const expected = authority.players.find((player) => player.id === playerId);
const reported = candidate.players.find((player) => player.id === playerId);
return Boolean(
expected &&
reported &&
reported.energy !== null &&
Math.abs(reported.energy - expected.energy) <= 12 &&
Math.abs(candidate.core - authority.core) <= 0.3,
);
},
perceive(_authority, event, { playerId }) {
if (event.type === "round-won") return { ...event };
return {
id: event.id,
type: "overheated",
team: event.team,
scope: event.playerId === playerId ? "self" : "field",
};
},
},
input: {
validate(input) {
return typeof input === "object" && input !== null && typeof input.thrust === "boolean";
},
},
encoding: {
input: inputCodec,
clientState: stateCodec,
perception: perceptionCodec,
},
});
function createAuthorityState(): FluxAuthorityState {
return {
core: 0,
velocity: 0,
round: 1,
cyanScore: 0,
orangeScore: 0,
resetTicks: 0,
elapsedTicks: 0,
nextEventId: 1,
players: [
createPlayer(FLUX_BOT_IDS[0], "cyan", true),
createPlayer(FLUX_BOT_IDS[1], "orange", true),
],
};
}
function createClientState(): FluxClientState {
return {
core: 0,
velocity: 0,
round: 1,
cyanScore: 0,
orangeScore: 0,
resetTicks: 0,
elapsedTicks: 0,
players: [],
events: [],
};
}
function createPlayer(
id: number,
team: FluxTeam,
bot: boolean,
): FluxAuthorityPlayer {
return {
id,
team,
bot,
thrust: false,
energy: maximumEnergy,
overheated: false,
};
}
function updatePlayers(
state: FluxAuthorityState,
tick: number,
deltaSeconds: number,
emit: (event: FluxAuthorityEvent) => void,
allowThrust: boolean,
): void {
for (const player of state.players) {
if (player.bot) {
// Both bots use the same deterministic cadence, so they animate the field
// without biasing a solo player's match.
player.thrust = allowThrust && tick % 150 < 92 && !player.overheated;
}
if (player.thrust && allowThrust && !player.overheated) {
player.energy = clamp(
player.energy - energyDrainPerSecond * deltaSeconds,
0,
maximumEnergy,
);
if (player.energy === 0) {
player.thrust = false;
player.overheated = true;
emit({
id: state.nextEventId++,
type: "overheated",
playerId: player.id,
team: player.team,
});
}
} else {
player.energy = clamp(
player.energy + energyRecoveryPerSecond * deltaSeconds,
0,
maximumEnergy,
);
if (player.overheated && player.energy >= maximumEnergy * 0.34) {
player.overheated = false;
}
}
}
}
function updateVisibleEnergy(player: FluxPlayerView, deltaSeconds: number): void {
if (player.energy === null) return;
player.energy = clamp(
player.energy +
(player.thrust ? -energyDrainPerSecond : energyRecoveryPerSecond) * deltaSeconds,
0,
maximumEnergy,
);
if (player.energy === 0) player.thrust = false;
}
function activePlayerCount(state: FluxAuthorityState, team: FluxTeam): number {
return state.players.filter(
(player) => player.team === team && player.thrust && !player.overheated,
).length;
}
function visibleActiveCount(state: FluxClientState, team: FluxTeam): number {
return state.players.filter((player) => player.team === team && player.thrust).length;
}
function leastPopulatedTeam(state: FluxAuthorityState): FluxTeam {
const humans = state.players.filter((player) => !player.bot);
const cyan = humans.filter((player) => player.team === "cyan").length;
const orange = humans.length - cyan;
return cyan <= orange ? "cyan" : "orange";
}
function cloneAuthorityState(state: FluxAuthorityState): FluxAuthorityState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
};
}
function cloneClientState(state: FluxClientState): FluxClientState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
events: state.events.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
})),
};
}
function sortPlayers(players: FluxAuthorityPlayer[]): void {
players.sort((left, right) => left.id - right.id);
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,88 @@
export type FluxTeam = "cyan" | "orange";
export interface FluxInput {
thrust: boolean;
}
export interface FluxAuthorityPlayer {
id: number;
team: FluxTeam;
bot: boolean;
thrust: boolean;
energy: number;
overheated: boolean;
}
export interface FluxAuthorityState {
core: number;
velocity: number;
round: number;
cyanScore: number;
orangeScore: number;
resetTicks: number;
elapsedTicks: number;
nextEventId: number;
players: FluxAuthorityPlayer[];
}
export interface FluxPlayerView {
id: number;
team: FluxTeam;
bot: boolean;
thrust: boolean;
/** Exact energy is private: null for every player except the viewer. */
energy: number | null;
}
export type FluxPerception =
| {
id: number;
type: "round-won";
team: FluxTeam;
round: number;
}
| {
id: number;
type: "overheated";
team: FluxTeam;
scope: "self" | "field";
};
export interface FluxPresentationEvent {
receivedTick: number;
event: FluxPerception;
}
export interface FluxClientState {
core: number;
velocity: number;
round: number;
cyanScore: number;
orangeScore: number;
resetTicks: number;
elapsedTicks: number;
players: FluxPlayerView[];
events: FluxPresentationEvent[];
}
export type FluxAuthorityEvent =
| {
id: number;
type: "round-won";
team: FluxTeam;
round: number;
}
| {
id: number;
type: "overheated";
playerId: number;
team: FluxTeam;
};
export interface FluxGameContract {
authority: FluxAuthorityState;
client: FluxClientState;
input: FluxInput;
authorityEvent: FluxAuthorityEvent;
perceptionEvent: FluxPerception;
}

View File

@@ -0,0 +1,16 @@
export const API_PATHS = { message: "/api/message" } as const;
export const GAME_SOCKET_PATH = "/ws/game";
export interface ApiMessage {
message: string;
timestamp: string;
}
export * from "./arena.js";
export * from "./flux-types.js";
export * from "./flux-game.js";
export * from "./royale-types.js";
export * from "./royale-map.js";
export * from "./royale-game.js";
export * from "./shooter-types.js";
export * from "./shooter-game.js";

View File

@@ -0,0 +1,834 @@
import {
createJsonCodec,
defineMultiplayerGame,
withSpatialReplication,
} from "@syncer/engine";
import {
ROYALE_CHUNK_SIZE,
ROYALE_MAP_SEED,
ROYALE_WORLD_RADIUS,
ROYALE_WORLD_SIZE,
collidesWithRoyaleMap,
hasRoyaleLineOfSight,
royaleChunkCoordinate,
royaleRandom,
} from "./royale-map.js";
import type {
RoyaleAuthorityEvent,
RoyaleAuthorityLoot,
RoyaleAuthorityPlayer,
RoyaleAuthorityState,
RoyaleClientState,
RoyaleGameContract,
RoyaleInput,
RoyaleLootKind,
RoyalePerception,
RoyalePlayerView,
RoyaleWeapon,
} from "./royale-types.js";
export const ROYALE_SOCKET_PATH = "/ws/royale";
export const ROYALE_TICK_RATE = 30;
export const ROYALE_SNAPSHOT_RATE = 10;
export const ROYALE_BOT_COUNT = 31;
export const ROYALE_BOT_ID_BASE = 30_000;
export const ROYALE_WEAPONS: Record<
RoyaleWeapon,
{
name: string;
damage: number;
range: number;
cooldownTicks: number;
magazine: number;
reserve: number;
reloadTicks: number;
}
> = {
pistol: { name: "Kestrel P9", damage: 19, range: 125, cooldownTicks: 8, magazine: 12, reserve: 48, reloadTicks: 38 },
rifle: { name: "VX-4 Carbine", damage: 15, range: 205, cooldownTicks: 4, magazine: 30, reserve: 120, reloadTicks: 52 },
marksman: { name: "Longwatch DMR", damage: 48, range: 340, cooldownTicks: 17, magazine: 8, reserve: 32, reloadTicks: 64 },
};
const movementAcceleration = 52;
const movementFriction = 10;
const walkSpeed = 11.5;
const sprintSpeed = 16.5;
const parachuteSpeed = 22;
const parachuteDescentPerSecond = 18;
const playerEyeHeight = 1.55;
const playerHitRadius = 1.25;
const presentationEventLifetime = ROYALE_TICK_RATE * 6;
const initialStormRadius = 900;
const finalStormRadius = 78;
const stormShrinkPerTick = 0.085;
const lootCount = 260;
const inputCodec = createJsonCodec<RoyaleInput>();
const stateCodec = createJsonCodec<RoyaleClientState>();
const eventCodec = createJsonCodec<RoyalePerception>();
const baseRoyaleGame = defineMultiplayerGame<RoyaleGameContract>({
clock: {
ticksPerSecond: ROYALE_TICK_RATE,
snapshotsPerSecond: ROYALE_SNAPSHOT_RATE,
},
authority: {
createInitialState: createAuthorityState,
cloneState: cloneAuthorityState,
addPlayer(state, { playerId }) {
state.players.push(createPlayer(state, playerId, false, 110));
state.players.sort((left, right) => left.id - right.id);
},
removePlayer(state, { playerId }) {
state.players = state.players.filter((player) => player.id !== playerId);
},
applyInput(state, input, { playerId }) {
const player = findPlayer(state, playerId);
if (!player || player.bot || !player.alive) return;
applyCommand(player, input);
if (input.reload) beginReload(player);
},
step(state, { tick, deltaSeconds, emit }) {
if (state.resetTicks > 0) {
state.resetTicks -= 1;
if (state.resetTicks === 0) resetMatch(state);
return;
}
state.elapsedTicks += 1;
state.stormRadius = Math.max(
finalStormRadius,
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
);
for (const player of state.players) {
if (!player.alive) continue;
updateTimers(player);
if (player.botState) updateBot(state, player, tick);
const landed = simulatePlayer(player, deltaSeconds);
if (landed) {
emit({ id: state.nextEventId++, type: "landed", playerId: player.id });
}
if (player.altitude <= 0) {
collectLoot(state, player, emit);
if (player.firing) tryFire(state, player, emit);
applyStormDamage(state, player, tick, emit);
}
}
const alive = state.players.filter((player) => player.alive);
if (state.elapsedTicks > ROYALE_TICK_RATE * 8 && alive.length <= 1 && state.players.length > 1) {
const winner = alive[0] ?? null;
state.winnerId = winner?.id ?? null;
state.resetTicks = ROYALE_TICK_RATE * 5;
if (winner) {
emit({
id: state.nextEventId++,
type: "winner",
playerId: winner.id,
round: state.round,
});
}
}
},
validateState(state) {
return (
Number.isFinite(state.stormRadius) &&
state.stormRadius >= finalStormRadius &&
state.players.every(
(player) =>
Number.isFinite(player.x + player.z + player.health + player.altitude) &&
Number.isFinite(player.yaw + player.pitch) &&
player.health >= 0 &&
player.health <= 100 &&
player.armor >= 0 &&
player.armor <= 100,
)
);
},
},
prediction: {
createInitialState: createClientState,
cloneState: cloneClientState,
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (!player || !player.alive) return;
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.25, 1.25);
player.firing = input.fire;
player.sprinting = input.sprint;
if (input.reload && player.reloadTicks === 0) {
player.reloadTicks = ROYALE_WEAPONS[player.weapon].reloadTicks;
}
},
step(state, { tick, deltaSeconds }) {
state.elapsedTicks += 1;
state.stormRadius = Math.max(
finalStormRadius,
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
);
for (const player of state.players) {
if (!player.alive) continue;
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.reloadTicks > 0) player.reloadTicks -= 1;
simulateVisiblePlayer(player, deltaSeconds);
}
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - presentationEventLifetime,
);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const merged = cloneClientState(snapshot);
merged.events = predicted.events
.filter((entry) => entry.receivedTick >= tick - presentationEventLifetime)
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
if (state.events.length > 64) state.events.shift();
},
validateState(state) {
return (
Number.isFinite(state.stormRadius) &&
state.players.every(
(player) =>
Number.isFinite(player.x + player.z + player.altitude) &&
Number.isFinite(player.yaw + player.pitch) &&
(player.health === null || (player.health >= 0 && player.health <= 100)),
)
);
},
},
visibility: {
createSnapshot(authority, { playerId }) {
const viewer = findPlayer(authority, playerId);
const players = viewer
? authority.players
.filter((target) => target.id === playerId || canSeePlayer(viewer, target))
.map((player) => clonePlayerView(player, player.id === playerId))
: [];
const loot = viewer
? authority.loot
.filter(
(item) =>
item.active && Math.hypot(item.x - viewer.x, item.z - viewer.z) <= 155,
)
.map(({ id, kind, weapon, amount, x, z }) => ({ id, kind, weapon, amount, x, z }))
: [];
return {
mapSeed: ROYALE_MAP_SEED,
worldSize: ROYALE_WORLD_SIZE,
chunkSize: ROYALE_CHUNK_SIZE,
players,
loot,
elapsedTicks: authority.elapsedTicks,
round: authority.round,
aliveCount: authority.players.filter((player) => player.alive).length,
stormX: authority.stormX,
stormZ: authority.stormZ,
stormRadius: authority.stormRadius,
resetTicks: authority.resetTicks,
winnerId: authority.winnerId,
stream: {
candidateCount: players.length + loot.length,
droppedCount: 0,
usedBytes: 0,
budgetBytes: 0,
chunkX: viewer ? royaleChunkCoordinate(viewer.x) : 0,
chunkZ: viewer ? royaleChunkCoordinate(viewer.z) : 0,
},
events: [],
};
},
validateClientState(authority, candidate, { playerId }) {
const expected = findPlayer(authority, playerId);
const reported = candidate.players.find((player) => player.id === playerId);
return Boolean(
expected &&
reported &&
reported.health === expected.health &&
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 5,
);
},
perceive(authority, event, { playerId }) {
return perceiveEvent(authority, event, playerId);
},
},
input: {
validate(input) {
return (
typeof input === "object" &&
input !== null &&
Number.isFinite(input.forward) &&
Number.isFinite(input.strafe) &&
Number.isFinite(input.yaw) &&
Number.isFinite(input.pitch) &&
Math.abs(input.forward) <= 1 &&
Math.abs(input.strafe) <= 1 &&
input.pitch >= -1.35 &&
input.pitch <= 1.35 &&
typeof input.fire === "boolean" &&
typeof input.sprint === "boolean" &&
typeof input.reload === "boolean"
);
},
},
encoding: {
input: inputCodec,
clientState: stateCodec,
perception: eventCodec,
},
});
export const royaleGame = withSpatialReplication(baseRoyaleGame, {
cellSize: ROYALE_CHUNK_SIZE,
bandwidthBudgetBytesPerSecond: 30_000,
reservedBytesPerSnapshot: 300,
viewerPosition(authority, playerId) {
const viewer = findPlayer(authority, playerId);
return viewer ? { x: viewer.x, z: viewer.z } : null;
},
sources: [
{
category: "players",
maximumDistance: 360,
entities: (authority) => authority.players.filter((player) => player.alive),
estimatedBytes: 76,
priority: (_entity, distance) => 1_000 - distance,
required: (entity, playerId) => entity.id === playerId,
},
{
category: "loot",
maximumDistance: 165,
entities: (authority) => authority.loot.filter((item) => item.active),
estimatedBytes: 44,
priority: (_entity, distance) => 300 - distance,
},
],
projectSnapshot(snapshot, selection) {
const visibleCandidates = snapshot.players.length + snapshot.loot.length;
const players = snapshot.players.filter((player) => selection.has("players", player.id));
const loot = snapshot.loot.filter((item) => selection.has("loot", item.id));
return {
...snapshot,
players,
loot,
stream: {
candidateCount: visibleCandidates,
droppedCount: visibleCandidates - players.length - loot.length,
usedBytes: players.length * 76 + loot.length * 44,
budgetBytes: selection.budgetBytes,
chunkX: royaleChunkCoordinate(selection.position.x),
chunkZ: royaleChunkCoordinate(selection.position.z),
},
};
},
perceive(_authority, _event, perception, selection) {
if (perception.type === "shot" && !selection.has("players", perception.sourceId)) {
return null;
}
return perception;
},
});
function createAuthorityState(): RoyaleAuthorityState {
const secretSeed = randomSeed();
const state: RoyaleAuthorityState = {
players: [],
loot: [],
elapsedTicks: 0,
round: 1,
stormX: (royaleRandom(secretSeed, 901) - 0.5) * 180,
stormZ: (royaleRandom(secretSeed, 902) - 0.5) * 180,
stormRadius: initialStormRadius,
resetTicks: 0,
winnerId: null,
nextEventId: 1,
secretSeed,
};
state.loot = createLoot(secretSeed);
for (let index = 0; index < ROYALE_BOT_COUNT; index += 1) {
const id = ROYALE_BOT_ID_BASE + index;
state.players.push(createPlayer(state, id, true, 0));
}
return state;
}
function createClientState(): RoyaleClientState {
return {
mapSeed: ROYALE_MAP_SEED,
worldSize: ROYALE_WORLD_SIZE,
chunkSize: ROYALE_CHUNK_SIZE,
players: [],
loot: [],
elapsedTicks: 0,
round: 1,
aliveCount: 0,
stormX: 0,
stormZ: 0,
stormRadius: initialStormRadius,
resetTicks: 0,
winnerId: null,
stream: { candidateCount: 0, droppedCount: 0, usedBytes: 0, budgetBytes: 0, chunkX: 0, chunkZ: 0 },
events: [],
};
}
function createPlayer(
state: RoyaleAuthorityState,
id: number,
bot: boolean,
altitude: number,
): RoyaleAuthorityPlayer {
const spawn = findSpawn(state.secretSeed, id + state.round * 101);
const weapon = bot && id % 5 === 0 ? "rifle" : "pistol";
const config = ROYALE_WEAPONS[weapon];
return {
id,
bot,
alive: true,
x: spawn.x,
z: spawn.z,
velocityX: 0,
velocityZ: 0,
yaw: spawn.yaw,
pitch: 0,
altitude,
health: 100,
armor: bot ? 20 : 0,
weapon,
magazine: config.magazine,
reserve: config.reserve,
cooldownTicks: 0,
reloadTicks: 0,
kills: 0,
inputForward: 0,
inputStrafe: 0,
firing: false,
sprinting: false,
botState: bot ? { phase: id % 360, targetId: null } : null,
};
}
function applyCommand(player: RoyaleAuthorityPlayer, input: RoyaleInput): void {
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.25, 1.25);
player.firing = input.fire;
player.sprinting = input.sprint;
}
function updateBot(state: RoyaleAuthorityState, bot: RoyaleAuthorityPlayer, tick: number): void {
const target = nearestTarget(state, bot, 280);
bot.botState!.targetId = target?.id ?? null;
let targetX = state.stormX;
let targetZ = state.stormZ;
if (target) {
targetX = target.x;
targetZ = target.z;
} else {
const angle = (bot.botState!.phase + tick * 0.18) * (Math.PI / 180);
targetX += Math.cos(angle) * Math.min(190, state.stormRadius * 0.45);
targetZ += Math.sin(angle) * Math.min(190, state.stormRadius * 0.45);
}
const distance = Math.hypot(targetX - bot.x, targetZ - bot.z);
bot.yaw = Math.atan2(targetX - bot.x, targetZ - bot.z) + Math.sin(tick * 0.17 + bot.id) * 0.035;
bot.pitch = target
? clamp(Math.atan2(target.altitude - bot.altitude, Math.max(0.01, distance)), -1.25, 1.25)
: 0;
bot.inputForward = distance > 45 ? 1 : 0.24;
bot.inputStrafe = target ? Math.sin(tick * 0.08 + bot.id) * 0.7 : 0;
bot.sprinting = distance > 90;
bot.firing = Boolean(
target &&
distance <= ROYALE_WEAPONS[bot.weapon].range &&
hasRoyaleLineOfSight(bot.x, bot.z, target.x, target.z),
);
if (bot.magazine === 0) beginReload(bot);
}
function simulatePlayer(player: RoyaleAuthorityPlayer, deltaSeconds: number): boolean {
const wasAirborne = player.altitude > 0;
const speed =
player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
const sin = Math.sin(player.yaw);
const cos = Math.cos(player.yaw);
let directionX = sin * player.inputForward - cos * player.inputStrafe;
let directionZ = cos * player.inputForward + sin * player.inputStrafe;
const magnitude = Math.hypot(directionX, directionZ);
if (magnitude > 1) {
directionX /= magnitude;
directionZ /= magnitude;
}
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
if (magnitude < 0.01) {
player.velocityX *= Math.max(0, 1 - movementFriction * deltaSeconds);
player.velocityZ *= Math.max(0, 1 - movementFriction * deltaSeconds);
}
const nextX = player.x + player.velocityX * deltaSeconds;
const nextZ = player.z + player.velocityZ * deltaSeconds;
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
else player.velocityX = 0;
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
else player.velocityZ = 0;
if (player.altitude > 0) {
player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
}
return wasAirborne && player.altitude === 0;
}
function simulateVisiblePlayer(player: RoyalePlayerView & { inputForward?: number; inputStrafe?: number }, deltaSeconds: number): void {
const inputForward = player.inputForward ?? 0;
const inputStrafe = player.inputStrafe ?? 0;
const speed = player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
const sin = Math.sin(player.yaw);
const cos = Math.cos(player.yaw);
let directionX = sin * inputForward - cos * inputStrafe;
let directionZ = cos * inputForward + sin * inputStrafe;
const magnitude = Math.hypot(directionX, directionZ);
if (magnitude > 1) {
directionX /= magnitude;
directionZ /= magnitude;
}
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
const nextX = player.x + player.velocityX * deltaSeconds;
const nextZ = player.z + player.velocityZ * deltaSeconds;
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
if (player.altitude > 0) player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
}
function tryFire(
state: RoyaleAuthorityState,
shooter: RoyaleAuthorityPlayer,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) return;
const weapon = ROYALE_WEAPONS[shooter.weapon];
shooter.magazine -= 1;
shooter.cooldownTicks = weapon.cooldownTicks;
emit({
id: state.nextEventId++,
type: "shot",
sourceId: shooter.id,
x: shooter.x,
y: shooter.altitude + playerEyeHeight,
z: shooter.z,
yaw: shooter.yaw,
pitch: shooter.pitch,
});
const horizontal = Math.cos(shooter.pitch);
const directionX = Math.sin(shooter.yaw) * horizontal;
const directionY = Math.sin(shooter.pitch);
const directionZ = Math.cos(shooter.yaw) * horizontal;
const originY = shooter.altitude + playerEyeHeight;
let nearest: RoyaleAuthorityPlayer | null = null;
let nearestDistance = weapon.range;
for (const target of state.players) {
if (!target.alive || target.id === shooter.id) continue;
const offsetX = target.x - shooter.x;
const offsetY = target.altitude + playerEyeHeight - originY;
const offsetZ = target.z - shooter.z;
const forward =
offsetX * directionX + offsetY * directionY + offsetZ * directionZ;
if (forward <= 0 || forward >= nearestDistance) continue;
const offsetSquared = offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ;
const missDistance = Math.sqrt(Math.max(0, offsetSquared - forward * forward));
if (
missDistance > playerHitRadius ||
!hasRoyaleLineOfSight(shooter.x, shooter.z, target.x, target.z)
) continue;
nearest = target;
nearestDistance = forward;
}
if (nearest) damagePlayer(state, shooter.id, nearest, weapon.damage, emit);
if (shooter.magazine === 0) beginReload(shooter);
}
function damagePlayer(
state: RoyaleAuthorityState,
sourceId: number,
target: RoyaleAuthorityPlayer,
amount: number,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
const absorbed = Math.min(target.armor, Math.ceil(amount * 0.55));
target.armor -= absorbed;
const healthDamage = Math.min(target.health, amount - absorbed);
target.health -= healthDamage;
emit({
id: state.nextEventId++,
type: "damage",
sourceId,
targetId: target.id,
amount: healthDamage,
health: target.health,
});
if (target.health > 0) return;
target.alive = false;
target.firing = false;
target.velocityX = 0;
target.velocityZ = 0;
const killer = findPlayer(state, sourceId);
if (killer) killer.kills += 1;
emit({ id: state.nextEventId++, type: "elimination", killerId: sourceId, victimId: target.id });
}
function applyStormDamage(
state: RoyaleAuthorityState,
player: RoyaleAuthorityPlayer,
tick: number,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
if (tick % 15 !== 0) return;
if (Math.hypot(player.x - state.stormX, player.z - state.stormZ) <= state.stormRadius) return;
damagePlayer(state, 0, player, 3, emit);
}
function collectLoot(
state: RoyaleAuthorityState,
player: RoyaleAuthorityPlayer,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
const item = state.loot.find(
(candidate) => candidate.active && Math.hypot(candidate.x - player.x, candidate.z - player.z) < 2.4,
);
if (!item) return;
item.active = false;
if (item.kind === "weapon") {
player.weapon = item.weapon;
player.magazine = ROYALE_WEAPONS[item.weapon].magazine;
player.reserve = Math.max(player.reserve, ROYALE_WEAPONS[item.weapon].reserve / 2);
} else if (item.kind === "ammo") {
player.reserve = Math.min(240, player.reserve + item.amount);
} else if (item.kind === "armor") {
player.armor = Math.min(100, player.armor + item.amount);
} else {
player.health = Math.min(100, player.health + item.amount);
}
emit({
id: state.nextEventId++,
type: "pickup",
playerId: player.id,
kind: item.kind,
weapon: item.weapon,
amount: item.amount,
});
}
function perceiveEvent(
authority: RoyaleAuthorityState,
event: RoyaleAuthorityEvent,
playerId: number,
): RoyalePerception | null {
const viewer = findPlayer(authority, playerId);
if (!viewer) return null;
switch (event.type) {
case "shot": {
const source = findPlayer(authority, event.sourceId);
if (source && (source.id === viewer.id || canSeePlayer(viewer, source))) {
return { ...event };
}
const dx = event.x - viewer.x;
const dz = event.z - viewer.z;
const distance = Math.hypot(dx, dz);
if (distance > 560) return null;
return {
id: event.id,
type: "gunfire",
bearing: normalizeAngle(Math.atan2(dx, dz) - viewer.yaw),
intensity: clamp(1 - distance / 560, 0.08, 1),
};
}
case "damage":
if (event.targetId === playerId) {
return { id: event.id, type: "damage", amount: event.amount, health: event.health };
}
if (event.sourceId === playerId) {
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.health === 0 };
}
return null;
case "elimination":
return { ...event };
case "pickup":
return event.playerId === playerId
? { id: event.id, type: "pickup", kind: event.kind, weapon: event.weapon, amount: event.amount }
: null;
case "landed":
return event.playerId === playerId ? { id: event.id, type: "landed" } : null;
case "winner":
return { ...event };
}
}
function canSeePlayer(viewer: RoyaleAuthorityPlayer, target: RoyaleAuthorityPlayer): boolean {
if (!target.alive || target.id === viewer.id) return false;
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
return distance <= 225 && hasRoyaleLineOfSight(viewer.x, viewer.z, target.x, target.z);
}
function clonePlayerView(player: RoyaleAuthorityPlayer, owner: boolean): RoyalePlayerView {
return {
id: player.id,
bot: player.bot,
alive: player.alive,
x: player.x,
z: player.z,
velocityX: player.velocityX,
velocityZ: player.velocityZ,
yaw: player.yaw,
pitch: player.pitch,
altitude: player.altitude,
health: owner ? player.health : null,
armor: owner ? player.armor : null,
weapon: player.weapon,
magazine: owner ? player.magazine : null,
reserve: owner ? player.reserve : null,
cooldownTicks: player.cooldownTicks,
reloadTicks: player.reloadTicks,
kills: player.kills,
firing: player.firing,
sprinting: player.sprinting,
inputForward: owner ? player.inputForward : 0,
inputStrafe: owner ? player.inputStrafe : 0,
};
}
function createLoot(seed: number): RoyaleAuthorityLoot[] {
const loot: RoyaleAuthorityLoot[] = [];
for (let id = 1; id <= lootCount; id += 1) {
let position = findSpawn(seed ^ 0xa51c, id * 31);
const roll = royaleRandom(seed ^ 0xb771, id);
const kind: RoyaleLootKind = roll > 0.82 ? "weapon" : roll > 0.55 ? "ammo" : roll > 0.28 ? "armor" : "medkit";
const weapon: RoyaleWeapon = royaleRandom(seed ^ 0x991a, id) > 0.78 ? "marksman" : "rifle";
if (collidesWithRoyaleMap(position.x, position.z, 1.2)) position = findSpawn(seed ^ 0x4411, id * 67);
loot.push({
id,
kind,
weapon,
amount: kind === "ammo" ? 30 : kind === "armor" ? 35 : kind === "medkit" ? 30 : 1,
x: position.x,
z: position.z,
active: true,
});
}
return loot;
}
function findSpawn(seed: number, index: number): { x: number; z: number; yaw: number } {
for (let attempt = 0; attempt < 24; attempt += 1) {
const radius = 120 + Math.sqrt(royaleRandom(seed, index + attempt * 3)) * 690;
const angle = royaleRandom(seed ^ 0x7f4a, index + attempt * 3 + 1) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
if (!collidesWithRoyaleMap(x, z, 1.2)) return { x, z, yaw: angle + Math.PI };
}
return { x: 0, z: 0, yaw: 0 };
}
function nearestTarget(
state: RoyaleAuthorityState,
source: RoyaleAuthorityPlayer,
maximumDistance: number,
): RoyaleAuthorityPlayer | null {
let nearest: RoyaleAuthorityPlayer | null = null;
let distance = maximumDistance;
for (const candidate of state.players) {
if (!candidate.alive || candidate.id === source.id || candidate.altitude > 0) continue;
const candidateDistance = Math.hypot(candidate.x - source.x, candidate.z - source.z);
if (candidateDistance < distance) {
nearest = candidate;
distance = candidateDistance;
}
}
return nearest;
}
function beginReload(player: RoyaleAuthorityPlayer): void {
const weapon = ROYALE_WEAPONS[player.weapon];
if (player.reloadTicks > 0 || player.magazine >= weapon.magazine || player.reserve <= 0) return;
player.reloadTicks = weapon.reloadTicks;
player.firing = false;
}
function updateTimers(player: RoyaleAuthorityPlayer): void {
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.reloadTicks > 0) {
player.reloadTicks -= 1;
if (player.reloadTicks === 0) {
const capacity = ROYALE_WEAPONS[player.weapon].magazine;
const loaded = Math.min(capacity - player.magazine, player.reserve);
player.magazine += loaded;
player.reserve -= loaded;
}
}
}
function resetMatch(state: RoyaleAuthorityState): void {
state.elapsedTicks = 0;
state.round += 1;
state.winnerId = null;
state.stormRadius = initialStormRadius;
state.stormX = (royaleRandom(state.secretSeed, 901 + state.round) - 0.5) * 180;
state.stormZ = (royaleRandom(state.secretSeed, 902 + state.round) - 0.5) * 180;
state.loot = createLoot(state.secretSeed ^ state.round * 0x9e37);
for (const player of state.players) {
const fresh = createPlayer(state, player.id, player.bot, player.bot ? 0 : 110);
Object.assign(player, fresh, { kills: player.kills });
}
}
function cloneAuthorityState(state: RoyaleAuthorityState): RoyaleAuthorityState {
return {
...state,
players: state.players.map((player) => ({
...player,
botState: player.botState ? { ...player.botState } : null,
})),
loot: state.loot.map((item) => ({ ...item })),
};
}
function cloneClientState(state: RoyaleClientState): RoyaleClientState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
loot: state.loot.map((item) => ({ ...item })),
stream: { ...state.stream },
events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })),
};
}
function findPlayer(state: RoyaleAuthorityState, playerId: number): RoyaleAuthorityPlayer | undefined {
return state.players.find((player) => player.id === playerId);
}
function randomSeed(): number {
return Math.floor(Math.random() * 0x1_0000_0000) >>> 0;
}
function approach(value: number, target: number, amount: number): number {
return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount);
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,143 @@
export const ROYALE_MAP_SEED = 0x51_9c_ae_27;
export const ROYALE_WORLD_SIZE = 2_000;
export const ROYALE_WORLD_RADIUS = 960;
export const ROYALE_CHUNK_SIZE = 100;
export type RoyaleBiome = "meadow" | "forest" | "highland" | "coast";
export type RoyaleObstacleKind = "building" | "rock" | "tree";
export interface RoyaleObstacle {
id: string;
kind: RoyaleObstacleKind;
x: number;
z: number;
width: number;
depth: number;
height: number;
}
export interface RoyaleMapChunk {
x: number;
z: number;
biome: RoyaleBiome;
tint: number;
elevation: number;
roadX: boolean;
roadZ: boolean;
obstacles: RoyaleObstacle[];
}
const chunkCache = new Map<string, RoyaleMapChunk>();
/** Public, deterministic static world generation shared by server and clients. */
export function generateRoyaleChunk(chunkX: number, chunkZ: number): RoyaleMapChunk {
const key = `${chunkX}:${chunkZ}`;
const cached = chunkCache.get(key);
if (cached) return cached;
const centerX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
const centerZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
const normalizedRadius = Math.hypot(centerX, centerZ) / ROYALE_WORLD_RADIUS;
const biomeNoise = random01(PROCEDURAL_SEED, chunkX, chunkZ, 1);
const biome: RoyaleBiome =
normalizedRadius > 0.84
? "coast"
: biomeNoise > 0.68
? "forest"
: biomeNoise < 0.2
? "highland"
: "meadow";
const roadX = Math.abs(chunkZ) % 5 === 0;
const roadZ = Math.abs(chunkX + 2) % 6 === 0;
const obstacleCount = biome === "coast" ? 2 : 4 + Math.floor(random01(PROCEDURAL_SEED, chunkX, chunkZ, 2) * 5);
const obstacles: RoyaleObstacle[] = [];
for (let index = 0; index < obstacleCount; index += 1) {
const localX = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 10 + index * 5) - 0.5) * 82;
const localZ = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 11 + index * 5) - 0.5) * 82;
if ((roadZ && Math.abs(localX) < 11) || (roadX && Math.abs(localZ) < 11)) continue;
const kindRoll = random01(PROCEDURAL_SEED, chunkX, chunkZ, 12 + index * 5);
const kind: RoyaleObstacleKind =
kindRoll > 0.72 ? "building" : kindRoll > 0.38 ? "tree" : "rock";
const scale = random01(PROCEDURAL_SEED, chunkX, chunkZ, 13 + index * 5);
const width = kind === "building" ? 10 + scale * 15 : kind === "tree" ? 3.2 : 4 + scale * 7;
const depth = kind === "building" ? 9 + random01(PROCEDURAL_SEED, chunkX, chunkZ, 14 + index * 5) * 14 : width;
obstacles.push({
id: `${chunkX}:${chunkZ}:${index}`,
kind,
x: chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localX,
z: chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localZ,
width,
depth,
height: kind === "building" ? 7 + scale * 13 : kind === "tree" ? 8 + scale * 7 : 2.5 + scale * 5,
});
}
const chunk: RoyaleMapChunk = {
x: chunkX,
z: chunkZ,
biome,
tint:
biome === "coast" ? 0xb9a56e : biome === "forest" ? 0x315d3f : biome === "highland" ? 0x697064 : 0x52764b,
elevation: Math.round(random01(PROCEDURAL_SEED, chunkX, chunkZ, 3) * 3) * 0.16,
roadX,
roadZ,
obstacles,
};
chunkCache.set(key, chunk);
return chunk;
}
export function collidesWithRoyaleMap(x: number, z: number, radius = 0.72): boolean {
if (Math.hypot(x, z) + radius > ROYALE_WORLD_RADIUS) return true;
const chunkX = Math.floor(x / ROYALE_CHUNK_SIZE);
const chunkZ = Math.floor(z / ROYALE_CHUNK_SIZE);
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) {
for (const obstacle of generateRoyaleChunk(chunkX + offsetX, chunkZ + offsetZ).obstacles) {
const nearestX = clamp(x, obstacle.x - obstacle.width / 2, obstacle.x + obstacle.width / 2);
const nearestZ = clamp(z, obstacle.z - obstacle.depth / 2, obstacle.z + obstacle.depth / 2);
const dx = x - nearestX;
const dz = z - nearestZ;
if (dx * dx + dz * dz < radius * radius) return true;
}
}
}
return false;
}
export function hasRoyaleLineOfSight(fromX: number, fromZ: number, toX: number, toZ: number): boolean {
const distance = Math.hypot(toX - fromX, toZ - fromZ);
const steps = Math.max(2, Math.ceil(distance / 4));
for (let step = 1; step < steps; step += 1) {
const ratio = step / steps;
if (collidesWithRoyaleMap(fromX + (toX - fromX) * ratio, fromZ + (toZ - fromZ) * ratio, 0.12)) {
return false;
}
}
return true;
}
export function royaleChunkCoordinate(value: number): number {
return Math.floor(value / ROYALE_CHUNK_SIZE);
}
export function royaleRandom(seed: number, index: number): number {
return random01(seed, index, index * 17 + 3, index * 97 + 11);
}
const PROCEDURAL_SEED = ROYALE_MAP_SEED;
function random01(seed: number, a: number, b: number, c: number): number {
let value = seed ^ Math.imul(a, 0x9e3779b1) ^ Math.imul(b, 0x85ebca77) ^ Math.imul(c, 0xc2b2ae3d);
value ^= value >>> 16;
value = Math.imul(value, 0x7feb352d);
value ^= value >>> 15;
value = Math.imul(value, 0x846ca68b);
value ^= value >>> 16;
return (value >>> 0) / 0x1_0000_0000;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,160 @@
export type RoyaleWeapon = "pistol" | "rifle" | "marksman";
export type RoyaleLootKind = "weapon" | "ammo" | "armor" | "medkit";
export interface RoyaleInput {
forward: number;
strafe: number;
yaw: number;
pitch: number;
fire: boolean;
sprint: boolean;
reload: boolean;
}
export interface RoyaleBotState {
phase: number;
targetId: number | null;
}
export interface RoyaleAuthorityPlayer {
id: number;
bot: boolean;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
altitude: number;
health: number;
armor: number;
weapon: RoyaleWeapon;
magazine: number;
reserve: number;
cooldownTicks: number;
reloadTicks: number;
kills: number;
inputForward: number;
inputStrafe: number;
firing: boolean;
sprinting: boolean;
botState: RoyaleBotState | null;
}
export interface RoyaleAuthorityLoot {
id: number;
kind: RoyaleLootKind;
weapon: RoyaleWeapon;
amount: number;
x: number;
z: number;
active: boolean;
}
export interface RoyaleAuthorityState {
players: RoyaleAuthorityPlayer[];
loot: RoyaleAuthorityLoot[];
elapsedTicks: number;
round: number;
stormX: number;
stormZ: number;
stormRadius: number;
resetTicks: number;
winnerId: number | null;
nextEventId: number;
secretSeed: number;
}
export interface RoyalePlayerView {
id: number;
bot: boolean;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
altitude: number;
/** Exact combat state exists only on the owning player's projection. */
health: number | null;
armor: number | null;
weapon: RoyaleWeapon;
magazine: number | null;
reserve: number | null;
cooldownTicks: number;
reloadTicks: number;
kills: number;
firing: boolean;
sprinting: boolean;
inputForward: number;
inputStrafe: number;
}
export interface RoyaleLootView {
id: number;
kind: RoyaleLootKind;
weapon: RoyaleWeapon;
amount: number;
x: number;
z: number;
}
export interface RoyaleStreamStats {
candidateCount: number;
droppedCount: number;
usedBytes: number;
budgetBytes: number;
chunkX: number;
chunkZ: number;
}
export interface RoyaleClientState {
mapSeed: number;
worldSize: number;
chunkSize: number;
players: RoyalePlayerView[];
loot: RoyaleLootView[];
elapsedTicks: number;
round: number;
aliveCount: number;
stormX: number;
stormZ: number;
stormRadius: number;
resetTicks: number;
winnerId: number | null;
stream: RoyaleStreamStats;
events: RoyalePresentationEvent[];
}
export type RoyaleAuthorityEvent =
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number }
| { id: number; type: "elimination"; killerId: number; victimId: number }
| { id: number; type: "pickup"; playerId: number; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
| { id: number; type: "landed"; playerId: number }
| { id: number; type: "winner"; playerId: number; round: number };
export type RoyalePerception =
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
| { id: number; type: "gunfire"; bearing: number; intensity: number }
| { id: number; type: "damage"; amount: number; health: number }
| { id: number; type: "hit"; amount: number; eliminated: boolean }
| { id: number; type: "elimination"; killerId: number; victimId: number }
| { id: number; type: "pickup"; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
| { id: number; type: "landed" }
| { id: number; type: "winner"; playerId: number; round: number };
export interface RoyalePresentationEvent {
receivedTick: number;
event: RoyalePerception;
}
export interface RoyaleGameContract {
authority: RoyaleAuthorityState;
client: RoyaleClientState;
input: RoyaleInput;
authorityEvent: RoyaleAuthorityEvent;
perceptionEvent: RoyalePerception;
}

View File

@@ -0,0 +1,506 @@
import type { BinaryCodec } from "@syncer/engine";
import {
isWeaponId,
type DistanceBand,
type ImpactKind,
type LoadoutState,
type PickupKind,
type PlayerKind,
type PlayerState,
type PickupState,
type ScoreEntry,
type ShooterInput,
type ShooterPerception,
type ShooterWorldState,
type WeaponId,
} from "./shooter-types.js";
const INPUT_BYTES = 18;
const WORLD_HEADER_BYTES = 17;
const PLAYER_BYTES = 55;
const SCORE_BYTES = 9;
const PICKUP_BYTES = 15;
export const shooterInputCodec: BinaryCodec<ShooterInput> = {
encode(input) {
const payload = new Uint8Array(INPUT_BYTES);
const view = new DataView(payload.buffer);
view.setFloat32(0, input.strafe, true);
view.setFloat32(4, input.forward, true);
view.setFloat32(8, input.yaw, true);
view.setFloat32(12, input.pitch, true);
view.setUint8(
16,
(input.fire ? 1 : 0) | (input.sprint ? 2 : 0) | (input.reload ? 4 : 0),
);
view.setUint8(17, input.weapon);
return payload;
},
decode(payload) {
assertLength(payload, INPUT_BYTES, "Shooter input");
const view = viewOf(payload);
const weapon = view.getUint8(17);
if (!isWeaponId(weapon)) {
throw new RangeError("Unknown weapon in shooter input");
}
const flags = view.getUint8(16);
return {
strafe: view.getFloat32(0, true),
forward: view.getFloat32(4, true),
yaw: view.getFloat32(8, true),
pitch: view.getFloat32(12, true),
fire: Boolean(flags & 1),
sprint: Boolean(flags & 2),
reload: Boolean(flags & 4),
weapon,
};
},
};
export const shooterWorldCodec: BinaryCodec<ShooterWorldState> = {
encode(state) {
if (
state.players.size > 65_535 ||
state.scoreboard.length > 65_535 ||
state.pickups.size > 65_535
) {
throw new RangeError("Shooter world exceeds the binary protocol limit");
}
const length =
WORLD_HEADER_BYTES +
state.players.size * PLAYER_BYTES +
state.scoreboard.length * SCORE_BYTES +
state.pickups.size * PICKUP_BYTES;
const payload = new Uint8Array(length);
const view = new DataView(payload.buffer);
let offset = 0;
view.setUint8(offset, 1);
offset += 1;
view.setUint32(offset, state.match.elapsedTicks, true);
offset += 4;
view.setUint16(offset, state.match.scoreLimit, true);
offset += 2;
view.setUint32(offset, state.match.leaderId, true);
offset += 4;
view.setUint16(offset, state.players.size, true);
offset += 2;
view.setUint16(offset, state.scoreboard.length, true);
offset += 2;
view.setUint16(offset, state.pickups.size, true);
offset += 2;
const players = [...state.players.values()].sort((a, b) => a.id - b.id);
for (const player of players) {
offset = encodePlayer(view, offset, player);
}
for (const score of [...state.scoreboard].sort((a, b) => a.id - b.id)) {
offset = encodeScore(view, offset, score);
}
for (const pickup of [...state.pickups.values()].sort((a, b) => a.id - b.id)) {
view.setUint16(offset, pickup.id, true);
view.setUint8(offset + 2, pickupKindToByte(pickup.kind));
view.setUint8(offset + 3, pickup.weapon);
view.setFloat32(offset + 4, pickup.x, true);
view.setFloat32(offset + 8, pickup.z, true);
view.setUint8(offset + 12, pickup.active ? 1 : 0);
view.setUint16(offset + 13, clampUint16(pickup.respawnTicks), true);
offset += PICKUP_BYTES;
}
return payload;
},
decode(payload) {
if (payload.byteLength < WORLD_HEADER_BYTES) {
throw new RangeError("Shooter world is missing its header");
}
const view = viewOf(payload);
let offset = 0;
const version = view.getUint8(offset);
offset += 1;
if (version !== 1) {
throw new RangeError(`Unsupported shooter world version ${version}`);
}
const elapsedTicks = view.getUint32(offset, true);
offset += 4;
const scoreLimit = view.getUint16(offset, true);
offset += 2;
const leaderId = view.getUint32(offset, true);
offset += 4;
const playerCount = view.getUint16(offset, true);
offset += 2;
const scoreCount = view.getUint16(offset, true);
offset += 2;
const pickupCount = view.getUint16(offset, true);
offset += 2;
assertLength(
payload,
WORLD_HEADER_BYTES +
playerCount * PLAYER_BYTES +
scoreCount * SCORE_BYTES +
pickupCount * PICKUP_BYTES,
"Shooter world",
);
const players = new Map<number, PlayerState>();
for (let index = 0; index < playerCount; index += 1) {
const decoded = decodePlayer(view, offset);
players.set(decoded.player.id, decoded.player);
offset = decoded.offset;
}
const scoreboard: ScoreEntry[] = [];
for (let index = 0; index < scoreCount; index += 1) {
const id = view.getUint32(offset, true);
const kind = byteToPlayerKind(view.getUint8(offset + 4));
scoreboard.push({
id,
kind,
kills: view.getUint16(offset + 5, true),
deaths: view.getUint16(offset + 7, true),
});
offset += SCORE_BYTES;
}
const pickups = new Map<number, PickupState>();
for (let index = 0; index < pickupCount; index += 1) {
const id = view.getUint16(offset, true);
const weapon = view.getUint8(offset + 3);
if (!isWeaponId(weapon)) {
throw new RangeError("Unknown pickup weapon");
}
pickups.set(id, {
id,
kind: byteToPickupKind(view.getUint8(offset + 2)),
weapon,
x: view.getFloat32(offset + 4, true),
z: view.getFloat32(offset + 8, true),
active: Boolean(view.getUint8(offset + 12)),
respawnTicks: view.getUint16(offset + 13, true),
});
offset += PICKUP_BYTES;
}
return {
players,
pickups,
scoreboard,
match: { elapsedTicks, scoreLimit, leaderId },
events: [],
};
},
};
export const shooterEventCodec: BinaryCodec<ShooterPerception> = {
encode(event) {
const length = perceptionLength(event);
const payload = new Uint8Array(length);
const view = new DataView(payload.buffer);
view.setUint32(0, event.id, true);
view.setUint8(4, perceptionTypeToByte(event.type));
switch (event.type) {
case "shot":
view.setUint8(5, event.weapon);
view.setUint32(6, event.sourceId, true);
view.setFloat32(10, event.originX, true);
view.setFloat32(14, event.originY, true);
view.setFloat32(18, event.originZ, true);
view.setFloat32(22, event.endX, true);
view.setFloat32(26, event.endY, true);
view.setFloat32(30, event.endZ, true);
view.setUint8(34, impactToByte(event.impact));
break;
case "sound":
view.setUint8(5, event.sound === "gunshot" ? 0 : 1);
view.setUint8(6, event.weapon);
view.setFloat32(7, event.bearingRadians, true);
view.setFloat32(11, event.intensity, true);
view.setUint8(15, distanceBandToByte(event.distanceBand));
break;
case "damage":
view.setUint16(5, clampUint16(event.amount), true);
view.setUint16(7, clampUint16(event.health), true);
view.setFloat32(9, event.bearingRadians, true);
view.setUint8(13, event.critical ? 1 : 0);
break;
case "hit":
view.setUint16(5, clampUint16(event.amount), true);
view.setUint8(7, event.eliminated ? 1 : 0);
view.setUint8(8, event.critical ? 1 : 0);
break;
case "elimination":
view.setUint32(5, event.killerId, true);
view.setUint32(9, event.victimId, true);
view.setUint8(13, event.weapon);
view.setUint8(14, event.critical ? 1 : 0);
break;
case "pickup":
view.setUint8(5, pickupKindToByte(event.pickup));
view.setUint8(6, event.weapon);
view.setUint16(7, clampUint16(event.amount), true);
break;
case "respawn":
break;
}
return payload;
},
decode(payload) {
if (payload.byteLength < 5) {
throw new RangeError("Shooter event is missing its header");
}
const view = viewOf(payload);
const id = view.getUint32(0, true);
const type = byteToPerceptionType(view.getUint8(4));
switch (type) {
case "shot": {
assertLength(payload, 35, "Shot event");
const weapon = decodeWeapon(view.getUint8(5));
return {
id,
type,
weapon,
sourceId: view.getUint32(6, true),
originX: view.getFloat32(10, true),
originY: view.getFloat32(14, true),
originZ: view.getFloat32(18, true),
endX: view.getFloat32(22, true),
endY: view.getFloat32(26, true),
endZ: view.getFloat32(30, true),
impact: byteToImpact(view.getUint8(34)),
};
}
case "sound": {
assertLength(payload, 16, "Sound event");
return {
id,
type,
sound: view.getUint8(5) === 0 ? "gunshot" : "footstep",
weapon: decodeWeapon(view.getUint8(6)),
bearingRadians: view.getFloat32(7, true),
intensity: view.getFloat32(11, true),
distanceBand: byteToDistanceBand(view.getUint8(15)),
};
}
case "damage":
assertLength(payload, 14, "Damage event");
return {
id,
type,
amount: view.getUint16(5, true),
health: view.getUint16(7, true),
bearingRadians: view.getFloat32(9, true),
critical: Boolean(view.getUint8(13)),
};
case "hit":
assertLength(payload, 9, "Hit event");
return {
id,
type,
amount: view.getUint16(5, true),
eliminated: Boolean(view.getUint8(7)),
critical: Boolean(view.getUint8(8)),
};
case "elimination":
assertLength(payload, 15, "Elimination event");
return {
id,
type,
killerId: view.getUint32(5, true),
victimId: view.getUint32(9, true),
weapon: decodeWeapon(view.getUint8(13)),
critical: Boolean(view.getUint8(14)),
};
case "pickup":
assertLength(payload, 9, "Pickup event");
return {
id,
type,
pickup: byteToPickupKind(view.getUint8(5)),
weapon: decodeWeapon(view.getUint8(6)),
amount: view.getUint16(7, true),
};
case "respawn":
assertLength(payload, 5, "Respawn event");
return { id, type };
}
},
};
function encodePlayer(view: DataView, offset: number, player: PlayerState): number {
view.setUint32(offset, player.id, true);
view.setUint8(
offset + 4,
(player.kind === "bot" ? 1 : 0) | (player.alive ? 2 : 0),
);
view.setFloat32(offset + 5, player.x, true);
view.setFloat32(offset + 9, player.z, true);
view.setFloat32(offset + 13, player.velocityX, true);
view.setFloat32(offset + 17, player.velocityZ, true);
view.setFloat32(offset + 21, player.yaw, true);
view.setFloat32(offset + 25, player.pitch, true);
view.setUint8(offset + 29, clampByte(player.health));
view.setUint8(offset + 30, clampByte(player.armor));
view.setUint8(offset + 31, player.weapon);
let ammoOffset = offset + 32;
for (const ammo of player.ammo) {
view.setUint16(ammoOffset, clampUint16(ammo.magazine), true);
view.setUint16(ammoOffset + 2, clampUint16(ammo.reserve), true);
ammoOffset += 4;
}
view.setUint16(offset + 44, clampUint16(player.cooldownTicks), true);
view.setUint16(offset + 46, clampUint16(player.reloadTicks), true);
view.setUint16(offset + 48, clampUint16(player.respawnTicks), true);
view.setUint16(offset + 50, clampUint16(player.spawnProtectionTicks), true);
view.setInt8(offset + 52, Math.round(clamp(player.inputStrafe, -1, 1) * 127));
view.setInt8(offset + 53, Math.round(clamp(player.inputForward, -1, 1) * 127));
view.setUint8(offset + 54, (player.firing ? 1 : 0) | (player.sprinting ? 2 : 0));
return offset + PLAYER_BYTES;
}
function decodePlayer(view: DataView, offset: number): { player: PlayerState; offset: number } {
const flags = view.getUint8(offset + 4);
const weapon = decodeWeapon(view.getUint8(offset + 31));
const ammo = [] as unknown as LoadoutState;
let ammoOffset = offset + 32;
for (let index = 0; index < 3; index += 1) {
ammo[index] = {
magazine: view.getUint16(ammoOffset, true),
reserve: view.getUint16(ammoOffset + 2, true),
};
ammoOffset += 4;
}
const inputFlags = view.getUint8(offset + 54);
return {
player: {
id: view.getUint32(offset, true),
kind: flags & 1 ? "bot" : "human",
alive: Boolean(flags & 2),
x: view.getFloat32(offset + 5, true),
z: view.getFloat32(offset + 9, true),
velocityX: view.getFloat32(offset + 13, true),
velocityZ: view.getFloat32(offset + 17, true),
yaw: view.getFloat32(offset + 21, true),
pitch: view.getFloat32(offset + 25, true),
health: view.getUint8(offset + 29),
armor: view.getUint8(offset + 30),
weapon,
ammo,
cooldownTicks: view.getUint16(offset + 44, true),
reloadTicks: view.getUint16(offset + 46, true),
respawnTicks: view.getUint16(offset + 48, true),
spawnProtectionTicks: view.getUint16(offset + 50, true),
inputStrafe: view.getInt8(offset + 52) / 127,
inputForward: view.getInt8(offset + 53) / 127,
firing: Boolean(inputFlags & 1),
sprinting: Boolean(inputFlags & 2),
},
offset: offset + PLAYER_BYTES,
};
}
function encodeScore(view: DataView, offset: number, score: ScoreEntry): number {
view.setUint32(offset, score.id, true);
view.setUint8(offset + 4, score.kind === "bot" ? 1 : 0);
view.setUint16(offset + 5, clampUint16(score.kills), true);
view.setUint16(offset + 7, clampUint16(score.deaths), true);
return offset + SCORE_BYTES;
}
function perceptionLength(event: ShooterPerception): number {
switch (event.type) {
case "shot": return 35;
case "sound": return 16;
case "damage": return 14;
case "hit": return 9;
case "elimination": return 15;
case "pickup": return 9;
case "respawn": return 5;
}
}
function perceptionTypeToByte(type: ShooterPerception["type"]): number {
return type === "shot" ? 0 : type === "sound" ? 1 : type === "damage" ? 2 : type === "elimination" ? 3 : type === "pickup" ? 4 : type === "respawn" ? 5 : 6;
}
function byteToPerceptionType(value: number): ShooterPerception["type"] {
if (value === 0) return "shot";
if (value === 1) return "sound";
if (value === 2) return "damage";
if (value === 3) return "elimination";
if (value === 4) return "pickup";
if (value === 5) return "respawn";
if (value === 6) return "hit";
throw new RangeError("Unknown shooter perception type");
}
function pickupKindToByte(kind: PickupKind): number {
return kind === "health" ? 0 : kind === "ammo" ? 1 : 2;
}
function byteToPickupKind(value: number): PickupKind {
if (value === 0) return "health";
if (value === 1) return "ammo";
if (value === 2) return "weapon";
throw new RangeError("Unknown pickup kind");
}
function distanceBandToByte(band: DistanceBand): number {
return band === "near" ? 0 : band === "medium" ? 1 : 2;
}
function byteToDistanceBand(value: number): DistanceBand {
if (value === 0) return "near";
if (value === 1) return "medium";
if (value === 2) return "far";
throw new RangeError("Unknown distance band");
}
function impactToByte(impact: ImpactKind): number {
return impact === "wall" ? 0 : impact === "player" ? 1 : 2;
}
function byteToImpact(value: number): ImpactKind {
if (value === 0) return "wall";
if (value === 1) return "player";
if (value === 2) return "miss";
throw new RangeError("Unknown impact kind");
}
function byteToPlayerKind(value: number): PlayerKind {
if (value === 0) return "human";
if (value === 1) return "bot";
throw new RangeError("Unknown player kind");
}
function decodeWeapon(value: number): WeaponId {
if (!isWeaponId(value)) {
throw new RangeError("Unknown weapon");
}
return value;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
function clampByte(value: number): number {
return Math.round(clamp(value, 0, 255));
}
function clampUint16(value: number): number {
return Math.round(clamp(value, 0, 65_535));
}
function viewOf(payload: Uint8Array): DataView {
return new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
}
function assertLength(payload: Uint8Array, expected: number, name: string): void {
if (payload.byteLength !== expected) {
throw new RangeError(`${name} must be exactly ${expected} bytes`);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
export const Weapon = {
PulseRifle: 0,
Scattergun: 1,
RailRifle: 2,
} as const;
export type WeaponId = (typeof Weapon)[keyof typeof Weapon];
export type PlayerKind = "human" | "bot";
export type PickupKind = "health" | "ammo" | "weapon";
export type DistanceBand = "near" | "medium" | "far";
export interface WeaponDefinition {
id: WeaponId;
name: string;
shortName: string;
damage: number;
pellets: number;
spread: number;
range: number;
cooldownTicks: number;
reloadTicks: number;
magazineSize: number;
startingReserve: number;
automatic: boolean;
accent: string;
}
export const WEAPONS: Readonly<Record<WeaponId, WeaponDefinition>> = {
[Weapon.PulseRifle]: { id: Weapon.PulseRifle, name: "Nova-9 Pulse Rifle", shortName: "NOVA-9", damage: 18, pellets: 1, spread: 0.012, range: 42, cooldownTicks: 6, reloadTicks: 92, magazineSize: 30, startingReserve: 120, automatic: true, accent: "#5de4c7" },
[Weapon.Scattergun]: { id: Weapon.Scattergun, name: "Arc-12 Scattergun", shortName: "ARC-12", damage: 10, pellets: 8, spread: 0.105, range: 16, cooldownTicks: 48, reloadTicks: 112, magazineSize: 8, startingReserve: 32, automatic: false, accent: "#ffad5c" },
[Weapon.RailRifle]: { id: Weapon.RailRifle, name: "Vantage Rail Rifle", shortName: "VANTAGE", damage: 72, pellets: 1, spread: 0.002, range: 58, cooldownTicks: 76, reloadTicks: 138, magazineSize: 4, startingReserve: 16, automatic: false, accent: "#a98cff" },
};
export interface ShooterInput {
strafe: number;
forward: number;
yaw: number;
pitch: number;
fire: boolean;
sprint: boolean;
reload: boolean;
weapon: WeaponId;
}
export interface AmmoState { magazine: number; reserve: number }
export type LoadoutState = [AmmoState, AmmoState, AmmoState];
export interface PlayerState {
id: number;
kind: PlayerKind;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
health: number;
armor: number;
weapon: WeaponId;
ammo: LoadoutState;
cooldownTicks: number;
reloadTicks: number;
respawnTicks: number;
spawnProtectionTicks: number;
inputStrafe: number;
inputForward: number;
firing: boolean;
sprinting: boolean;
}
export interface ScoreEntry { id: number; kind: PlayerKind; kills: number; deaths: number }
export interface PickupState {
id: number;
kind: PickupKind;
weapon: WeaponId;
x: number;
z: number;
active: boolean;
respawnTicks: number;
}
export interface MatchState { elapsedTicks: number; scoreLimit: number; leaderId: number }
export type SoundKind = "gunshot" | "footstep";
export type ImpactKind = "wall" | "player" | "miss";
export type ShooterPerception =
| { id: number; type: "shot"; weapon: WeaponId; sourceId: number; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
| { id: number; type: "sound"; sound: SoundKind; weapon: WeaponId; bearingRadians: number; intensity: number; distanceBand: DistanceBand }
| { id: number; type: "damage"; amount: number; health: number; bearingRadians: number; critical: boolean }
| { id: number; type: "hit"; amount: number; eliminated: boolean; critical: boolean }
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
| { id: number; type: "pickup"; pickup: PickupKind; weapon: WeaponId; amount: number }
| { id: number; type: "respawn" };
export interface TimedPerception { receivedTick: number; event: ShooterPerception }
export interface ShooterWorldState {
players: Map<number, PlayerState>;
pickups: Map<number, PickupState>;
scoreboard: ScoreEntry[];
match: MatchState;
events: TimedPerception[];
}
export interface BotMind { targetId: number | null; patrolIndex: number; nextThinkTick: number; strafeSign: number }
export interface AuthoritativePlayer extends PlayerState {
kills: number;
deaths: number;
lastFirePressed: boolean;
lastReloadPressed: boolean;
lastFootstepTick: number;
bot: BotMind | null;
}
export interface AuthoritativeShooterState {
players: Map<number, AuthoritativePlayer>;
pickups: Map<number, PickupState>;
elapsedTicks: number;
nextEventId: number;
}
export type ShooterAuthorityEvent =
| { id: number; type: "shot"; sourceId: number; weapon: WeaponId; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number; critical: boolean }
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
| { id: number; type: "pickup"; playerId: number; pickup: PickupKind; weapon: WeaponId; amount: number }
| { id: number; type: "respawn"; playerId: number }
| { id: number; type: "footstep"; sourceId: number };
export function isWeaponId(value: number): value is WeaponId {
return value === 0 || value === 1 || value === 2;
}
export function playerDisplayName(id: number, kind: PlayerKind): string {
if (kind === "human") return `RANGER ${id}`;
const names = ["CIPHER", "ROOK", "MARA", "KESTREL", "VEX", "HALO"];
return names[Math.abs(id) % names.length] ?? `BOT ${id}`;
}

View File

@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
FLUX_BOT_IDS,
FLUX_SNAPSHOT_RATE,
FLUX_TICK_RATE,
fluxGame,
} from "../dist/index.js";
function createThrustPacket(sequence, targetTick, thrust = true) {
return {
sequence,
targetTick,
observedTick: Math.max(0, targetTick - 1),
input: { thrust },
};
}
test("Flux Relay is a complete game defined through the public authoring API", () => {
assert.equal(fluxGame.tickRateHz, FLUX_TICK_RATE);
assert.equal(fluxGame.snapshotRateHz, FLUX_SNAPSHOT_RATE);
const server = fluxGame.createServer();
server.addPlayer(1);
assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), {
accepted: true,
});
const events = [];
for (let tick = 0; tick < 180; tick += 1) {
events.push(...server.step().events);
}
assert.ok(server.currentState.cyanScore >= 2);
assert.equal(server.currentState.orangeScore, 0);
assert.ok(
events.some((event) => event.type === "round-won" && event.team === "cyan"),
);
assert.ok(server.currentState.players.some((player) => player.id === FLUX_BOT_IDS[0]));
});
test("Flux snapshots disclose exact energy only to its owner", () => {
const server = fluxGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const playerOneView = server.createSnapshot(1, 0).state;
const playerTwoView = server.createSnapshot(2, 0).state;
assert.equal(playerOneView.players.find((player) => player.id === 1)?.energy, 100);
assert.equal(playerOneView.players.find((player) => player.id === 2)?.energy, null);
assert.equal(playerTwoView.players.find((player) => player.id === 1)?.energy, null);
assert.equal(playerTwoView.players.find((player) => player.id === 2)?.energy, 100);
assert.ok(
playerOneView.players
.filter((player) => player.bot)
.every((player) => player.energy === null),
);
assert.equal("nextEventId" in playerOneView, false);
});
test("the same private Flux event becomes a self cue or anonymous field cue", () => {
const server = fluxGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const player = server.currentState.players.find((candidate) => candidate.id === 1);
player.energy = 0.2;
server.submitInput(1, createThrustPacket(1, 1));
const result = server.step();
const overheat = result.events.find((event) => event.type === "overheated");
assert.ok(overheat);
const ownCue = server.createPerceptions(1, [overheat])[0];
const fieldCue = server.createPerceptions(2, [overheat])[0];
assert.equal(ownCue.scope, "self");
assert.equal(fieldCue.scope, "field");
assert.equal("playerId" in ownCue, false);
assert.equal("playerId" in fieldCue, false);
});
test("Flux uses the generic protocol, prediction, acknowledgement, and reconciliation", () => {
const server = fluxGame.createServer();
server.addPlayer(7);
const client = fluxGame.createClient();
client.initialize(7, server.createSnapshot(7, 10));
const packet = client.createInput({ thrust: true }, 1);
const wirePacket = fluxGame.protocol.decodeClient(
fluxGame.protocol.encodeClient({ kind: "input", packet }),
);
assert.equal(wirePacket.kind, "input");
assert.deepEqual(wirePacket.packet, packet);
assert.equal(server.submitInput(7, packet).accepted, true);
client.step();
const result = server.step();
client.acknowledge(result.acknowledgements[0].sequence);
const wireSnapshot = fluxGame.protocol.decodeServer(
fluxGame.protocol.encodeServer({
kind: "snapshot",
snapshot: server.createSnapshot(7, 20),
}),
);
assert.equal(wireSnapshot.kind, "snapshot");
client.reconcile(wireSnapshot.snapshot);
const local = client.currentState.players.find((player) => player.id === 7);
assert.equal(local.thrust, true);
assert.ok(local.energy < 100);
});

View File

@@ -0,0 +1,219 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ROYALE_BOT_COUNT,
ROYALE_BOT_ID_BASE,
ROYALE_CHUNK_SIZE,
ROYALE_MAP_SEED,
collidesWithRoyaleMap,
generateRoyaleChunk,
hasRoyaleLineOfSight,
royaleGame,
} from "../dist/index.js";
test("the public world seed deterministically generates a 2 km chunked island", () => {
const first = generateRoyaleChunk(-3, 4);
const second = generateRoyaleChunk(-3, 4);
const neighbor = generateRoyaleChunk(-2, 4);
assert.deepEqual(first, second);
assert.notDeepEqual(first, neighbor);
assert.equal(ROYAALE_MAP_SEED_FOR_TEST(), ROYALE_MAP_SEED);
assert.equal(ROYAALE_CHUNK_SIZE_FOR_TEST(), ROYALE_CHUNK_SIZE);
assert.ok(first.obstacles.every((obstacle) => obstacle.id.startsWith("-3:4:")));
});
test("Syncer Royale simulates a complete authority battle headlessly", () => {
const server = royaleGame.createServer();
const totals = { shots: 0, damage: 0, eliminations: 0, winners: 0 };
for (let tick = 0; tick < 6_000; tick += 1) {
for (const event of server.step().events) {
if (event.type === "shot") totals.shots += 1;
if (event.type === "damage") totals.damage += 1;
if (event.type === "elimination") totals.eliminations += 1;
if (event.type === "winner") totals.winners += 1;
}
}
assert.equal(server.currentState.players.length, ROYALE_BOT_COUNT);
assert.ok(totals.shots > 150, `expected firefights, saw ${totals.shots}`);
assert.ok(totals.damage > 50, `expected damage, saw ${totals.damage}`);
assert.ok(totals.eliminations > 15, `expected eliminations, saw ${totals.eliminations}`);
assert.ok(server.currentState.players.every((player) => Number.isFinite(player.x + player.z)));
});
test("Royale projections hide authority secrets and distant combatants", () => {
const server = royaleGame.createServer();
server.addPlayer(1);
const authority = server.currentState;
const viewer = authority.players.find((player) => player.id === 1);
const hidden = authority.players.find((player) => player.id === ROYALE_BOT_ID_BASE);
viewer.altitude = 0;
viewer.x = 0;
viewer.z = 0;
hidden.x = 430;
hidden.z = 0;
hidden.alive = true;
const snapshot = server.createSnapshot(1, 0).state;
assert.equal(snapshot.mapSeed, ROYALE_MAP_SEED);
assert.equal("secretSeed" in snapshot, false);
assert.ok(snapshot.players.some((player) => player.id === 1));
assert.ok(!snapshot.players.some((player) => player.id === hidden.id));
assert.ok(
snapshot.players
.filter((player) => player.id !== 1)
.every((player) => player.health === null && player.armor === null),
);
const [sound] = server.createPerceptions(1, [
{ id: 99, type: "shot", sourceId: hidden.id, x: hidden.x, y: 1.55, z: hidden.z, yaw: 0, pitch: 0 },
]);
assert.equal(sound.type, "gunfire");
assert.equal("sourceId" in sound, false);
assert.equal("x" in sound, false);
const [ownShot] = server.createPerceptions(1, [
{
id: 100,
type: "shot",
sourceId: viewer.id,
x: viewer.x,
y: 1.55,
z: viewer.z,
yaw: viewer.yaw,
pitch: viewer.pitch,
},
]);
assert.equal(ownShot.type, "shot");
assert.equal(ownShot.sourceId, viewer.id);
});
test("Royale enforces a viewer bandwidth budget after ordinary visibility", () => {
const server = royaleGame.createServer();
server.addPlayer(1);
const authority = server.currentState;
const viewer = authority.players.find((player) => player.id === 1);
viewer.altitude = 0;
viewer.x = 0;
viewer.z = 0;
for (const bot of authority.players.filter((player) => player.bot)) {
bot.x = 800;
bot.z = 0;
}
for (let index = 0; index < 100; index += 1) {
const item = authority.loot[index];
item.active = true;
item.x = (index % 10) * 2 - 9;
item.z = Math.floor(index / 10) * 2 - 9;
}
const snapshot = server.createSnapshot(1, 0).state;
assert.ok(snapshot.stream.droppedCount > 0);
assert.ok(snapshot.stream.usedBytes <= snapshot.stream.budgetBytes);
assert.ok(snapshot.loot.length < 100);
assert.equal(snapshot.players.filter((player) => player.id === 1).length, 1);
});
test("Royale input and projected state round-trip through the generic protocol", () => {
const server = royaleGame.createServer();
server.addPlayer(7);
const client = royaleGame.createClient();
const welcome = server.createSnapshot(7, 10);
client.initialize(7, welcome);
const packet = client.createInput({
forward: 1,
strafe: 0.25,
yaw: 0.7,
pitch: -0.2,
fire: false,
sprint: true,
reload: false,
}, 1);
const decoded = royaleGame.protocol.decodeClient(
royaleGame.protocol.encodeClient({ kind: "input", packet }),
);
assert.equal(decoded.kind, "input");
assert.deepEqual(decoded.packet, packet);
assert.equal(server.submitInput(7, decoded.packet).accepted, true);
server.step();
assert.equal(server.currentState.players.find((player) => player.id === 7).pitch, -0.2);
client.step();
client.reconcile(server.createSnapshot(7, 20));
assert.ok(client.currentState.players.some((player) => player.id === 7));
});
test("Royale pitch drives the authoritative three-dimensional hit ray", () => {
const server = royaleGame.createServer();
server.addPlayer(7);
server.addPlayer(8);
const authority = server.currentState;
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
const shooter = authority.players.find((player) => player.id === 7);
const target = authority.players.find((player) => player.id === 8);
let origin = null;
for (let x = -100; x <= 100 && !origin; x += 10) {
for (let z = -100; z <= 60; z += 10) {
if (
!collidesWithRoyaleMap(x, z) &&
!collidesWithRoyaleMap(x, z + 40) &&
hasRoyaleLineOfSight(x, z, x, z + 40)
) {
origin = { x, z };
break;
}
}
}
assert.ok(origin, "expected an unobstructed public-map firing lane");
Object.assign(shooter, { x: origin.x, z: origin.z, altitude: 0, yaw: 0 });
Object.assign(target, { x: origin.x, z: origin.z + 40, altitude: 20, health: 100, armor: 0 });
const pitch = Math.atan2(20, 40);
assert.equal(server.submitInput(7, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: {
forward: 0,
strafe: 0,
yaw: 0,
pitch,
fire: true,
sprint: false,
reload: false,
},
}).accepted, true);
const result = server.step();
assert.ok(result.events.some((event) => event.type === "damage" && event.targetId === 8));
assert.ok(target.health < 100);
});
test("Royale strafe-right matches the first-person camera's screen right", () => {
const server = royaleGame.createServer();
server.addPlayer(7);
const authority = server.currentState;
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
const player = authority.players.find((candidate) => candidate.id === 7);
Object.assign(player, { x: 0, z: 0, altitude: 20, yaw: 0 });
assert.equal(server.submitInput(7, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: {
forward: 0,
strafe: 1,
yaw: 0,
pitch: 0,
fire: false,
sprint: false,
reload: false,
},
}).accepted, true);
server.step();
assert.ok(player.velocityX < 0, "screen-right is world -X while facing world +Z");
});
function ROYAALE_MAP_SEED_FOR_TEST() {
return ROYALE_MAP_SEED;
}
function ROYAALE_CHUNK_SIZE_FOR_TEST() {
return ROYALE_CHUNK_SIZE;
}

View File

@@ -0,0 +1,282 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BOT_COUNT,
BOT_ID_BASE,
Weapon,
shooterGame,
} from "../dist/index.js";
function runHeadlessMatch(ticks) {
const server = shooterGame.createServer();
const totals = {
shots: 0,
damageEvents: 0,
eliminations: 0,
respawns: 0,
};
for (let tick = 0; tick < ticks; tick += 1) {
const result = server.step();
for (const event of result.events) {
if (event.type === "shot") totals.shots += 1;
if (event.type === "damage") totals.damageEvents += 1;
if (event.type === "elimination") totals.eliminations += 1;
if (event.type === "respawn") totals.respawns += 1;
}
}
return {
...totals,
tick: server.tick,
scores: [...server.currentState.players.values()]
.map((player) => ({
id: player.id,
kills: player.kills,
deaths: player.deaths,
alive: player.alive,
x: Number(player.x.toFixed(5)),
z: Number(player.z.toFixed(5)),
}))
.sort((a, b) => a.id - b.id),
};
}
test("the complete shooter match simulates headlessly on the backend", () => {
const match = runHeadlessMatch(7_200);
assert.equal(match.tick, 7_200);
assert.equal(match.scores.length, BOT_COUNT);
assert.ok(match.shots > 700, `expected active combat, saw ${match.shots} shots`);
assert.ok(match.damageEvents > 50, `expected hits, saw ${match.damageEvents}`);
assert.ok(match.eliminations >= 10, `expected kills, saw ${match.eliminations}`);
assert.ok(match.respawns >= 8, `expected respawns, saw ${match.respawns}`);
assert.equal(
match.scores.reduce((sum, score) => sum + score.kills, 0),
match.eliminations,
);
assert.ok(match.scores.every((score) => Number.isFinite(score.x + score.z)));
});
test("backend simulation is deterministic", () => {
assert.deepEqual(runHeadlessMatch(2_400), runHeadlessMatch(2_400));
});
test("human hitscan damages the historical target without rewinding live positions", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const shooter = server.currentState.players.get(1);
const target = server.currentState.players.get(BOT_ID_BASE);
for (const bot of server.currentState.players.values()) {
if (bot.id === BOT_ID_BASE || bot.id === 1) continue;
bot.alive = false;
bot.respawnTicks = 10_000;
}
shooter.x = -4;
shooter.z = -15;
shooter.yaw = Math.PI / 2;
shooter.pitch = 0;
shooter.spawnProtectionTicks = 0;
target.x = 0;
target.z = -15;
target.velocityX = 0;
target.velocityZ = 0;
target.inputForward = 0;
target.inputStrafe = 0;
target.spawnProtectionTicks = 0;
target.bot = null;
server.step();
target.z = -12.5;
const healthBefore = target.health;
server.submitInput(1, {
sequence: 1,
targetTick: 2,
observedTick: 1,
input: {
strafe: 0,
forward: 0,
yaw: Math.PI / 2,
pitch: 0,
fire: true,
sprint: false,
reload: false,
weapon: Weapon.PulseRifle,
},
});
const result = server.step();
assert.equal(target.z, -12.5, "the live target must not move backward");
assert.ok(target.health < healthBefore, "the rewound hitbox should take damage");
assert.ok(
result.events.some(
(event) => event.type === "damage" && event.targetId === BOT_ID_BASE,
),
);
const shot = result.events.find(
(event) => event.type === "shot" && event.sourceId === 1,
);
assert.ok(shot);
assert.ok(Math.abs(shot.endZ + 15) < 0.01);
});
test("shooter recordings seek through checkpoints with privacy intact", () => {
const server = shooterGame.createServer({ replaySeed: 99 });
server.addPlayer(1);
for (let tick = 0; tick < 420; tick += 1) server.step();
const recording = server.exportRecording();
assert.equal(recording.durationTicks, 420);
assert.deepEqual(
recording.checkpoints.map(({ tick }) => tick),
[0, 120, 240, 360],
);
const replay = shooterGame.createReplay(recording);
replay.seek(135);
const finalFrame = replay.seek(420);
assert.deepEqual(finalFrame.state, server.currentState);
const playerView = replay.viewAs(1).state;
assert.ok(playerView.players.has(1));
assert.equal("nextEventId" in playerView, false);
assert.ok(
[...playerView.players.values()].every((player) => !("bot" in player)),
);
});
test("a human elimination creates a bounded attacker-perspective killcam", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
let ticket = null;
for (let tick = 0; tick < 600 && !ticket; tick += 1) {
server.step();
ticket = server.drainReplayTickets(1)[0] ?? null;
}
assert.ok(ticket, "expected the standing human to receive a bot killcam");
assert.equal(ticket.requesterId, 1);
assert.ok(ticket.perspectiveId >= BOT_ID_BASE);
assert.equal(ticket.toTick, ticket.issuedAtTick);
assert.ok(ticket.frames.length > 60);
assert.ok(ticket.frames.length < 300);
assert.ok(
ticket.frames.every(({ state }) =>
[...state.players.values()].every(
(player) => !("bot" in player) && !("kills" in player),
),
),
);
});
test("snapshots and perception events cross the binary protocol", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const snapshot = server.createSnapshot(1, 123.5);
const snapshotFrame = shooterGame.protocol.encodeServer({
kind: "snapshot",
snapshot,
});
const decodedSnapshot = shooterGame.protocol.decodeServer(snapshotFrame);
assert.equal(decodedSnapshot.kind, "snapshot");
assert.equal(decodedSnapshot.snapshot.state.players.get(1).ammo.length, 3);
const event = {
id: 99,
type: "shot",
weapon: Weapon.RailRifle,
sourceId: 1,
originX: 1,
originY: 1.58,
originZ: 2,
endX: 11,
endY: 1.4,
endZ: -4,
impact: "wall",
};
const eventFrame = shooterGame.protocol.encodeServer({
kind: "event",
tick: 50,
event,
});
const decodedEvent = shooterGame.protocol.decodeServer(eventFrame);
assert.equal(decodedEvent.kind, "event");
assert.equal(decodedEvent.tick, 50);
assert.deepEqual(
{
...decodedEvent.event,
originY: Number(decodedEvent.event.originY.toFixed(2)),
endY: Number(decodedEvent.event.endY.toFixed(2)),
},
event,
);
});
test("occluded combatants stay private while their gunfire becomes anonymous audio", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const authority = server.currentState;
const viewer = authority.players.get(1);
const hiddenBot = authority.players.get(BOT_ID_BASE);
viewer.x = 0;
viewer.z = -6;
viewer.yaw = 0;
hiddenBot.x = 0;
hiddenBot.z = 6;
const snapshot = server.createSnapshot(1, 0);
assert.equal(snapshot.state.players.has(BOT_ID_BASE), false);
const perceptions = server.createPerceptions(1, [
{
id: 777,
type: "shot",
sourceId: BOT_ID_BASE,
weapon: Weapon.PulseRifle,
originX: hiddenBot.x,
originY: 1.58,
originZ: hiddenBot.z,
endX: hiddenBot.x,
endY: 1.58,
endZ: hiddenBot.z - 10,
impact: "wall",
},
]);
assert.equal(perceptions.length, 1);
assert.equal(perceptions[0].type, "sound");
assert.equal("sourceId" in perceptions[0], false);
assert.equal("originX" in perceptions[0], false);
});
test("visible opponents expose exact health but redact armor and inventory", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const viewer = server.currentState.players.get(1);
const opponent = server.currentState.players.get(BOT_ID_BASE);
viewer.x = -4;
viewer.z = -15;
viewer.yaw = Math.PI / 2;
opponent.x = 0;
opponent.z = -15;
opponent.health = 37;
opponent.armor = 19;
opponent.ammo[Weapon.PulseRifle].magazine = 3;
const replicated = server.createSnapshot(1, 0).state.players.get(BOT_ID_BASE);
assert.ok(replicated, "opponent should be visible in the clear corridor");
assert.equal(replicated.health, 37);
assert.equal(replicated.armor, 0);
assert.ok(
replicated.ammo.every(
(ammo) => ammo.magazine === 0 && ammo.reserve === 0,
),
);
assert.equal(replicated.x, opponent.x);
assert.equal(replicated.weapon, opponent.weapon);
viewer.yaw = -Math.PI / 2;
assert.equal(
server.createSnapshot(1, 0).state.players.has(BOT_ID_BASE),
false,
);
});

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}