add generic stateful input streaming
All checks were successful
build / image (push) Successful in 50s

This commit is contained in:
Syncer Deploy
2026-08-28 11:27:53 -03:00
parent 0bf2b95755
commit 96b73922cc
15 changed files with 767 additions and 81 deletions

View File

@@ -27,6 +27,16 @@ export {
type LagCompensationPolicies,
type LagCompensationResolutionContext,
} from "./lag-compensation.js";
export {
createInputStateStream,
InputStateStream,
withInputStream,
type InputStreamDefinition,
type InputStreamEmission,
type InputStreamNetworkedGame,
type InputStreamNeutralizeContext,
type InputStreamPolicy,
} from "./input-stream.js";
export { NetworkedPredictedEngine } from "./networked-client.js";
export {
NetworkedAuthoritativeEngine,

View File

@@ -0,0 +1,194 @@
import {
defineNetworkedGame,
type DefinedNetworkedGame,
} from "./define-networked-game.js";
import type { PlayerId } from "./types.js";
export interface InputStreamNeutralizeContext {
playerId: PlayerId;
tick: number;
sequence: number;
}
export interface InputStreamDefinition<Input> {
/** Stateful input refresh rate. Changes are still sent immediately. */
heartbeatRateHz?: number;
/** Authority fails safe to a neutral command after this silence window. */
timeoutMs?: number;
inputsEqual(left: Readonly<Input>, right: Readonly<Input>): boolean;
neutralize(
lastInput: Readonly<Input>,
context: InputStreamNeutralizeContext,
): Input;
/**
* Rebuilds persistent state after a timed-out connection resumes. Clear
* edge-triggered actions here so a heartbeat cannot execute them twice.
*/
resume?(
lastClientInput: Readonly<Input>,
context: InputStreamNeutralizeContext,
): Input;
}
export interface InputStreamPolicy<Input> {
readonly heartbeatRateHz: number;
readonly heartbeatIntervalMs: number;
readonly timeoutTicks: number;
readonly timeoutMs: number;
inputsEqual(left: Readonly<Input>, right: Readonly<Input>): boolean;
cloneInput(input: Readonly<Input>): Input;
neutralize(
lastInput: Readonly<Input>,
context: InputStreamNeutralizeContext,
): Input;
resume(
lastClientInput: Readonly<Input>,
context: InputStreamNeutralizeContext,
): Input;
}
export interface InputStreamEmission<Input> {
kind: "state" | "heartbeat";
input: Input;
}
/**
* Transport-independent latest-state sender. A changed command is available
* immediately; an unchanged command is made available only when its heartbeat
* is due.
*/
export class InputStateStream<Input> {
private current: Input | null = null;
private lastSent: Input | null = null;
private lastSentAt = Number.NEGATIVE_INFINITY;
private dirty = false;
constructor(readonly policy: InputStreamPolicy<Input>) {}
update(input: Readonly<Input>): void {
this.current = this.policy.cloneInput(input);
this.dirty =
this.lastSent === null ||
!this.policy.inputsEqual(this.current, this.lastSent);
}
reset(input?: Readonly<Input>): void {
this.current = input === undefined ? null : this.policy.cloneInput(input);
this.lastSent = null;
this.lastSentAt = Number.NEGATIVE_INFINITY;
this.dirty = input !== undefined;
}
/** Forces the current value to receive a new sequence on its next send. */
invalidate(): void {
this.lastSent = null;
this.lastSentAt = Number.NEGATIVE_INFINITY;
this.dirty = this.current !== null;
}
/** Returns the next state to transmit, or null when no send is due. */
consume(now: number, force = false): InputStreamEmission<Input> | null {
if (!Number.isFinite(now)) {
throw new RangeError("input stream time must be finite");
}
if (this.current === null) return null;
const heartbeatDue =
now - this.lastSentAt >= this.policy.heartbeatIntervalMs;
if (!force && !this.dirty && !heartbeatDue) return null;
const kind = this.dirty || this.lastSent === null ? "state" : "heartbeat";
const outgoing = this.policy.cloneInput(this.current);
this.lastSent = this.policy.cloneInput(this.current);
this.lastSentAt = now;
this.dirty = false;
return { kind, input: outgoing };
}
}
export function createInputStateStream<Input>(game: {
inputStream?: InputStreamPolicy<Input>;
}): InputStateStream<Input> {
if (!game.inputStream) {
throw new Error("Game is not configured with withInputStream()");
}
return new InputStateStream(game.inputStream);
}
export type InputStreamNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
> = DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> & {
readonly inputStream: InputStreamPolicy<Input>;
createInputStream(): InputStateStream<Input>;
};
/** Adds reliable latest-state delivery and a server-side dead-man timeout. */
export function withInputStream<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
>(
game: DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
definition: InputStreamDefinition<Input>,
): InputStreamNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> {
const heartbeatRateHz = definition.heartbeatRateHz ?? Math.min(20, game.tickRateHz);
const timeoutMs = definition.timeoutMs ?? 400;
if (!Number.isFinite(heartbeatRateHz) || heartbeatRateHz <= 0) {
throw new RangeError("heartbeatRateHz must be positive");
}
if (heartbeatRateHz > game.tickRateHz) {
throw new RangeError("heartbeatRateHz cannot exceed the simulation tick rate");
}
if (!Number.isFinite(timeoutMs) || timeoutMs <= 1_000 / heartbeatRateHz) {
throw new RangeError("timeoutMs must exceed one heartbeat interval");
}
const cloneInput = (input: Readonly<Input>): Input => {
const encoded = game.codecs.input.encode(input as Input);
return game.codecs.input.decode(encoded);
};
const policy: InputStreamPolicy<Input> = Object.freeze({
heartbeatRateHz,
heartbeatIntervalMs: 1_000 / heartbeatRateHz,
timeoutTicks: Math.max(1, Math.ceil((timeoutMs / 1_000) * game.tickRateHz)),
timeoutMs,
inputsEqual: definition.inputsEqual,
cloneInput,
neutralize: definition.neutralize,
resume: definition.resume ?? ((input) => cloneInput(input)),
});
const wrapped = defineNetworkedGame({
...game,
inputStream: policy,
});
return Object.freeze({
...wrapped,
inputStream: policy,
createInputStream: () => new InputStateStream(policy),
});
}

View File

@@ -71,7 +71,10 @@ export class NetworkClock {
return clientNow + this.offset;
}
recommendedInputLeadTicks(tickRateHz: number, maximum = 8): number {
recommendedInputLeadTicks(
tickRateHz: number,
maximum = Math.max(8, Math.ceil(tickRateHz / 2)),
): number {
const tickMilliseconds = 1_000 / tickRateHz;
const latencyBudget = this.smoothedRtt / 2 + this.smoothedJitter * 2;

View File

@@ -38,6 +38,20 @@ export interface NetworkedSimulationCheckpoint<
playerId: PlayerId;
packet: InputPacket<Input>;
}>;
inputStreamStates: Array<{
playerId: PlayerId;
lastReceivedTick: number;
clientSequence: number;
clientTargetTick: number;
clientObservedTick: number;
clientInput: Input;
lastAppliedTick: number;
appliedSequence: number;
appliedInput: Input;
applied: boolean;
neutralized: boolean;
resumePending: boolean;
}>;
pendingEvents: AuthorityEvent[];
}
@@ -51,6 +65,20 @@ interface QueuedReport<ClientState> {
report: ClientStateReport<ClientState>;
}
interface InputStreamState<Input> {
lastReceivedTick: number;
clientSequence: number;
clientTargetTick: number;
clientObservedTick: number;
clientInput: Input;
lastAppliedTick: number;
appliedSequence: number;
appliedInput: Input;
applied: boolean;
neutralized: boolean;
resumePending: boolean;
}
export class NetworkedAuthoritativeEngine<
AuthorityState,
ClientState,
@@ -73,6 +101,10 @@ export class NetworkedAuthoritativeEngine<
private readonly lastReceivedSequence = new Map<PlayerId, number>();
private readonly acknowledgedSequence = new Map<PlayerId, number>();
private readonly history = new Map<number, AuthorityState>();
private readonly inputStreamStates = new Map<
PlayerId,
InputStreamState<Input>
>();
private queuedInputs: QueuedInput<Input>[] = [];
private queuedReports: QueuedReport<ClientState>[] = [];
private pendingEvents: AuthorityEvent[] = [];
@@ -142,6 +174,7 @@ export class NetworkedAuthoritativeEngine<
});
this.lastReceivedSequence.delete(playerId);
this.acknowledgedSequence.delete(playerId);
this.inputStreamStates.delete(playerId);
this.queuedInputs = this.queuedInputs.filter(
(queued) => queued.playerId !== playerId,
);
@@ -158,6 +191,27 @@ export class NetworkedAuthoritativeEngine<
}
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
if (packet.sequence === previousSequence && this.game.inputStream) {
const stream = this.inputStreamStates.get(playerId);
if (
!stream ||
stream.clientSequence !== packet.sequence ||
stream.clientTargetTick !== packet.targetTick ||
stream.clientObservedTick !== (packet.observedTick ?? packet.targetTick) ||
!this.game.inputStream.inputsEqual(stream.clientInput, packet.input)
) {
return { accepted: false, reason: "duplicate" };
}
stream.lastReceivedTick = this.currentTick;
if (
stream.applied &&
stream.neutralized &&
stream.appliedSequence === stream.clientSequence
) {
stream.resumePending = true;
}
return { accepted: true };
}
if (packet.sequence <= previousSequence) {
return { accepted: false, reason: "duplicate" };
}
@@ -174,6 +228,23 @@ export class NetworkedAuthoritativeEngine<
}
this.lastReceivedSequence.set(playerId, packet.sequence);
if (this.game.inputStream) {
const previous = this.inputStreamStates.get(playerId);
this.inputStreamStates.set(playerId, {
lastReceivedTick: this.currentTick,
clientSequence: packet.sequence,
clientTargetTick: packet.targetTick,
clientObservedTick: packet.observedTick ?? packet.targetTick,
clientInput: this.game.inputStream.cloneInput(packet.input),
lastAppliedTick: previous?.lastAppliedTick ?? this.currentTick,
appliedSequence: previous?.appliedSequence ?? packet.sequence,
appliedInput: previous?.appliedInput ??
this.game.inputStream.cloneInput(packet.input),
applied: previous?.applied ?? false,
neutralized: previous?.neutralized ?? false,
resumePending: false,
});
}
this.queuedInputs.push({ playerId, packet });
return { accepted: true };
}
@@ -234,6 +305,18 @@ export class NetworkedAuthoritativeEngine<
emit,
});
if (this.game.inputStream) {
const stream = this.inputStreamStates.get(playerId);
if (stream) {
stream.lastAppliedTick = this.currentTick;
stream.appliedSequence = packet.sequence;
stream.appliedInput = this.game.inputStream.cloneInput(packet.input);
stream.applied = true;
stream.neutralized = false;
stream.resumePending = false;
}
}
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
if (packet.sequence > previousAck) {
this.acknowledgedSequence.set(playerId, packet.sequence);
@@ -241,6 +324,9 @@ export class NetworkedAuthoritativeEngine<
}
}
this.applyInputResumes(emit);
this.applyInputTimeouts(emit);
this.game.server.step(this.state, {
tick: this.currentTick,
deltaSeconds,
@@ -344,6 +430,22 @@ export class NetworkedAuthoritativeEngine<
playerId,
packet: this.cloneInputPacket(packet),
})),
inputStreamStates: [...this.inputStreamStates].map(
([playerId, stream]) => ({
playerId,
lastReceivedTick: stream.lastReceivedTick,
clientSequence: stream.clientSequence,
clientTargetTick: stream.clientTargetTick,
clientObservedTick: stream.clientObservedTick,
clientInput: this.cloneInput(stream.clientInput),
lastAppliedTick: stream.lastAppliedTick,
appliedSequence: stream.appliedSequence,
appliedInput: this.cloneInput(stream.appliedInput),
applied: stream.applied,
neutralized: stream.neutralized,
resumePending: stream.resumePending,
}),
),
pendingEvents: [...this.pendingEvents],
};
}
@@ -379,6 +481,22 @@ export class NetworkedAuthoritativeEngine<
playerId,
packet: this.cloneInputPacket(packet),
}));
this.inputStreamStates.clear();
for (const stream of checkpoint.inputStreamStates) {
this.inputStreamStates.set(stream.playerId, {
lastReceivedTick: stream.lastReceivedTick,
clientSequence: stream.clientSequence,
clientTargetTick: stream.clientTargetTick,
clientObservedTick: stream.clientObservedTick,
clientInput: this.cloneInput(stream.clientInput),
lastAppliedTick: stream.lastAppliedTick,
appliedSequence: stream.appliedSequence,
appliedInput: this.cloneInput(stream.appliedInput),
applied: stream.applied,
neutralized: stream.neutralized,
resumePending: stream.resumePending,
});
}
this.queuedReports = [];
this.pendingEvents = [...checkpoint.pendingEvents];
this.history.clear();
@@ -401,15 +519,95 @@ export class NetworkedAuthoritativeEngine<
}
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),
input: this.cloneInput(packet.input),
};
}
private cloneInput(input: Input): Input {
const encoded = this.game.codecs.input.encode(input);
return this.game.codecs.input.decode(encoded);
}
private applyInputTimeouts(emit: (event: AuthorityEvent) => void): void {
const policy = this.game.inputStream;
if (!policy) return;
for (const [playerId, stream] of this.inputStreamStates) {
if (
!stream.applied ||
stream.neutralized ||
this.currentTick - stream.lastReceivedTick < policy.timeoutTicks
) {
continue;
}
const input = policy.neutralize(policy.cloneInput(stream.appliedInput), {
playerId,
tick: this.currentTick,
sequence: stream.appliedSequence,
});
const packet: InputPacket<Input> = {
sequence: stream.appliedSequence,
targetTick: this.currentTick,
observedTick: this.currentTick,
input,
};
if (!this.game.validateInput(input, this.inputContext(playerId, packet))) {
throw new Error("Input stream neutralizer produced an invalid input");
}
this.game.server.applyInput(this.state, input, {
...this.inputContext(playerId, packet),
emit,
});
stream.appliedInput = policy.cloneInput(input);
stream.neutralized = true;
stream.resumePending = false;
}
}
private applyInputResumes(emit: (event: AuthorityEvent) => void): void {
const policy = this.game.inputStream;
if (!policy) return;
for (const [playerId, stream] of this.inputStreamStates) {
if (
!stream.applied ||
!stream.neutralized ||
!stream.resumePending ||
stream.appliedSequence !== stream.clientSequence
) {
continue;
}
const input = policy.resume(policy.cloneInput(stream.clientInput), {
playerId,
tick: this.currentTick,
sequence: stream.clientSequence,
});
const packet: InputPacket<Input> = {
sequence: stream.clientSequence,
targetTick: this.currentTick,
observedTick: this.currentTick,
input,
};
if (!this.game.validateInput(input, this.inputContext(playerId, packet))) {
throw new Error("Input stream resume produced an invalid input");
}
this.game.server.applyInput(this.state, input, {
...this.inputContext(playerId, packet),
emit,
});
stream.lastAppliedTick = this.currentTick;
stream.appliedInput = policy.cloneInput(input);
stream.neutralized = false;
stream.resumePending = false;
}
}
private validateReport(
playerId: PlayerId,
report: ClientStateReport<ClientState>,

View File

@@ -6,6 +6,7 @@ import type {
PlayerId,
TickContext,
} from "./types.js";
import type { InputStreamPolicy } from "./input-stream.js";
export interface EmittingTickContext<Event> extends TickContext {
emit(event: Event): void;
@@ -112,6 +113,8 @@ export interface NetworkedGameDefinition<
PerceptionEvent
>;
validateInput(input: Input, context: InputContext): boolean;
/** Present when the game is wrapped with withInputStream(). */
inputStream?: InputStreamPolicy<Input>;
codecs: {
input: BinaryCodec<Input>;
state: BinaryCodec<ClientState>;

View File

@@ -877,6 +877,7 @@ function createSeededEngine<
client: game.client,
replication: game.replication,
validateInput: game.validateInput,
...(game.inputStream ? { inputStream: game.inputStream } : {}),
codecs: game.codecs,
};
return new NetworkedAuthoritativeEngine(definition, serverOptions);
@@ -969,6 +970,15 @@ function cloneSimulationCheckpoint<
playerId,
packet: cloneInputPacket(game, packet),
})),
inputStreamStates: checkpoint.inputStreamStates.map((stream) => ({
...stream,
clientInput: game.codecs.input.decode(
game.codecs.input.encode(stream.clientInput),
),
appliedInput: game.codecs.input.decode(
game.codecs.input.encode(stream.appliedInput),
),
})),
pendingEvents: [...checkpoint.pendingEvents],
};
}

View File

@@ -6,12 +6,14 @@ import {
ReplayDivergenceError,
SpatialGridIndex,
createBinaryProtocol,
createInputStateStream,
createJsonCodec,
deterministicHash,
defineGame,
defineMultiplayerGame,
defineNetworkedGame,
withLagCompensation,
withInputStream,
withReplayTransport,
withSpatialReplication,
withTimeTravel,
@@ -270,6 +272,158 @@ test("network clock estimates RTT, offset, and input lead", () => {
assert.equal(stats.clockOffset, 10);
assert.equal(clock.toServerTime(300), 310);
assert.equal(clock.recommendedInputLeadTicks(60), 4);
const distantClock = new NetworkClock();
const distantPing = distantClock.createPing(0);
distantClock.receivePong(
{
...distantPing,
serverReceivedAt: 1_000,
serverSentAt: 1_000,
},
2_000,
);
assert.equal(distantClock.recommendedInputLeadTicks(60), 30);
});
test("input-stream HOF heartbeats state, executes actions once, and fails safe", () => {
const codec = createJsonCodec();
const base = defineMultiplayerGame({
clock: { ticksPerSecond: 10, snapshotsPerSecond: 5 },
authority: {
createInitialState: () => ({ moving: false, actionHeld: false, actions: 0 }),
cloneState: (state) => ({ ...state }),
applyInput(state, input) {
state.moving = input.move;
if (input.action && !state.actionHeld) state.actions += 1;
state.actionHeld = input.action;
},
step() {},
},
prediction: {
createInitialState: () => ({ moving: false, actionHeld: false, actions: 0 }),
cloneState: (state) => ({ ...state }),
applyInput(state, input) {
state.moving = input.move;
state.actionHeld = input.action;
},
step() {},
},
visibility: {
createSnapshot: (authority) => ({ ...authority }),
},
input: {
validate: (input) =>
typeof input?.move === "boolean" && typeof input.action === "boolean",
},
encoding: { input: codec, clientState: codec },
});
const game = withInputStream(base, {
heartbeatRateHz: 5,
timeoutMs: 300,
inputsEqual: (left, right) =>
left.move === right.move && left.action === right.action,
neutralize: () => ({ move: false, action: false }),
resume: (lastClientInput) => ({
move: lastClientInput.move,
action: false,
}),
});
assert.equal(game.inputStream.timeoutTicks, 3);
const stream = createInputStateStream(game);
stream.update({ move: true, action: true });
assert.deepEqual(stream.consume(0), {
kind: "state",
input: { move: true, action: true },
});
assert.equal(stream.consume(199), null);
assert.deepEqual(stream.consume(200), {
kind: "heartbeat",
input: { move: true, action: true },
});
stream.update({ move: false, action: false });
assert.deepEqual(stream.consume(201), {
kind: "state",
input: { move: false, action: false },
});
const server = game.createServer();
server.addPlayer(1);
const packet = {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { move: true, action: true },
};
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
assert.deepEqual(server.step().acknowledgements, [
{ playerId: 1, sequence: 1 },
]);
assert.deepEqual(server.currentState, {
moving: true,
actionHeld: true,
actions: 1,
});
// The exact same sequence is a keepalive, not another action.
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
assert.deepEqual(
server.submitInput(1, {
...packet,
input: { move: false, action: true },
}),
{ accepted: false, reason: "duplicate" },
);
assert.deepEqual(
server.submitInput(1, { ...packet, observedTick: 1 }),
{ accepted: false, reason: "duplicate" },
);
server.step();
assert.equal(server.currentState.actions, 1);
// Silence trips the dead-man switch exactly once.
server.step();
server.step();
assert.deepEqual(server.currentState, {
moving: false,
actionHeld: false,
actions: 1,
});
// A delayed heartbeat restores state through resume(), with the action
// stripped so it still has exactly-once semantics.
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
server.step();
assert.deepEqual(server.currentState, {
moving: true,
actionHeld: false,
actions: 1,
});
const checkpoint = server.createSimulationCheckpoint();
const restored = game.createServer();
restored.restoreSimulationCheckpoint(checkpoint);
assert.deepEqual(restored.createSimulationCheckpoint(), checkpoint);
const recordedGame = withTimeTravel(game, {
createSeed: () => 0,
hashState: deterministicHash,
checkpointIntervalTicks: 2,
verificationIntervalTicks: 1,
});
const recordedServer = recordedGame.createServer({ replaySeed: 0 });
recordedServer.addPlayer(1);
recordedServer.submitInput(1, packet);
recordedServer.step();
recordedServer.submitInput(1, packet);
recordedServer.step();
recordedServer.step();
recordedServer.step();
recordedServer.submitInput(1, packet);
recordedServer.step();
const replay = recordedGame.createReplay(recordedServer.exportRecording());
assert.deepEqual(replay.seek(recordedServer.tick).state, recordedServer.currentState);
});
test("binary protocol round-trips generic inputs, state, and control frames", () => {

View File

@@ -1,6 +1,7 @@
import {
createJsonCodec,
defineMultiplayerGame,
withInputStream,
} from "@syncer/engine";
import type {
FluxAuthorityEvent,
@@ -36,7 +37,7 @@ const perceptionCodec = createJsonCodec<FluxPerception>();
* 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>({
const baseFluxGame = defineMultiplayerGame<FluxGameContract>({
clock: {
ticksPerSecond: FLUX_TICK_RATE,
snapshotsPerSecond: FLUX_SNAPSHOT_RATE,
@@ -220,6 +221,13 @@ export const fluxGame = defineMultiplayerGame<FluxGameContract>({
},
});
export const fluxGame = withInputStream(baseFluxGame, {
heartbeatRateHz: 20,
timeoutMs: 400,
inputsEqual: (left, right) => left.thrust === right.thrust,
neutralize: () => ({ thrust: false }),
});
function createAuthorityState(): FluxAuthorityState {
return {
core: 0,

View File

@@ -1,6 +1,7 @@
import {
createJsonCodec,
defineMultiplayerGame,
withInputStream,
withSpatialReplication,
} from "@syncer/engine";
import {
@@ -290,7 +291,36 @@ const baseRoyaleGame = defineMultiplayerGame<RoyaleGameContract>({
},
});
export const royaleGame = withSpatialReplication(baseRoyaleGame, {
const streamedRoyaleGame = withInputStream(baseRoyaleGame, {
heartbeatRateHz: 20,
timeoutMs: 400,
inputsEqual(left, right) {
return (
left.forward === right.forward &&
left.strafe === right.strafe &&
left.yaw === right.yaw &&
left.pitch === right.pitch &&
left.fire === right.fire &&
left.sprint === right.sprint &&
left.reload === right.reload
);
},
neutralize(lastInput) {
return {
...lastInput,
forward: 0,
strafe: 0,
fire: false,
sprint: false,
reload: false,
};
},
resume(lastInput) {
return { ...lastInput, reload: false };
},
});
export const royaleGame = withSpatialReplication(streamedRoyaleGame, {
cellSize: ROYALE_CHUNK_SIZE,
bandwidthBudgetBytesPerSecond: 30_000,
reservedBytesPerSnapshot: 300,

View File

@@ -1,6 +1,7 @@
import {
defineNetworkedGame,
deterministicHash,
withInputStream,
withLagCompensation,
withReplayTransport,
withTimeTravel,
@@ -242,7 +243,41 @@ const shooterDefinition = defineNetworkedGame<
},
});
const lagCompensatedShooterDefinition = withLagCompensation(shooterDefinition, {
const streamedShooterDefinition = withInputStream(shooterDefinition, {
heartbeatRateHz: 20,
timeoutMs: 400,
inputsEqual(left, right) {
return (
left.strafe === right.strafe &&
left.forward === right.forward &&
left.yaw === right.yaw &&
left.pitch === right.pitch &&
left.fire === right.fire &&
left.sprint === right.sprint &&
left.reload === right.reload &&
left.weapon === right.weapon
);
},
neutralize(lastInput) {
return {
...lastInput,
strafe: 0,
forward: 0,
fire: false,
sprint: false,
reload: false,
};
},
resume(lastInput) {
return {
...lastInput,
fire: lastInput.fire && WEAPONS[lastInput.weapon].automatic,
reload: false,
};
},
});
const lagCompensatedShooterDefinition = withLagCompensation(streamedShooterDefinition, {
historySeconds: 0.5,
captureState(state) {
return {

View File

@@ -22,12 +22,16 @@ test("Flux Relay is a complete game defined through the public authoring API", (
const server = fluxGame.createServer();
server.addPlayer(1);
assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), {
const thrustPacket = createThrustPacket(1, 1);
assert.deepEqual(server.submitInput(1, thrustPacket), {
accepted: true,
});
const events = [];
for (let tick = 0; tick < 180; tick += 1) {
if (tick > 0 && tick % 6 === 0) {
assert.deepEqual(server.submitInput(1, thrustPacket), { accepted: true });
}
events.push(...server.step().events);
}