@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
260 lines • 10.5 kB
TypeScript
/**
* Client-side orchestrator for the predict-reconcile model.
*
* Encapsulates the algorithm previously inlined in
* `app/src/prototype/network_prototype_prediction.js`:
*
* - Per tick: sample input, store it in the {@link InputRing}, fire
* {@link onPredict} so the user's game code can execute SimAction(s)
* representing the input. The action gets recorded into the local
* action_log via the executor (capturing prior-state for later rewind),
* and goes out over the action stream when `peer.end_tick` flushes it.
*
* - On AUTH_STATE arrival: rewind the world to end-of-(server_frame - 1)
* via the {@link RewindEngine}, deserialize the server's authoritative
* state bytes via {@link onApplyAuthState}, then replay forward by
* firing {@link onReplay} for every frame in the InputRing between
* `server_frame + 1` and the client's current frame. The user's
* {@link onReplay} handler re-executes the same SimActions the original
* predict path produced.
*
* - A no-op short-circuit is built in: before doing the rewind+replay, the
* orchestrator gives the user a chance to compute what the replay WOULD
* produce via {@link onComputeExpected}; if that matches the current
* local state within a tolerance, the rewind is skipped entirely. This
* keeps the steady-state reconciliation loop from oscillating.
*
* The orchestrator does NOT bake in input encoding — the user provides
* serialize/deserialize/apply hooks. This keeps `MovePlayerAction` (and
* any other input action shape) decoupled from the orchestrator.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class ServerAuthoritativeClient {
/**
* @param {{
* world: EntityComponentDataset,
* binary_registry: BinarySerializationRegistry,
* replicated_components: Function[],
* action_classes: Function[],
* scope_filter?: object,
* frame_capacity?: number,
* initial_buffer_size?: number,
* input_ring_capacity?: number,
* input_byte_size?: number,
* reconcile_epsilon?: number,
* time_dilation?: TimeDilation,
* }} options
*
* `time_dilation`: optional pre-configured {@link TimeDilation}.
* On TIME_DILATION feedback the client recomputes
* {@link dilation_factor} for the caller to scale its tick cadence.
* Defaults to a stock instance; `dilation_factor` is 1.0 until the
* first feedback arrives.
*/
constructor({ world, binary_registry, replicated_components, action_classes, scope_filter, frame_capacity, initial_buffer_size, input_ring_capacity, input_byte_size, reconcile_epsilon, time_dilation, }: {
world: EntityComponentDataset;
binary_registry: BinarySerializationRegistry;
replicated_components: Function[];
action_classes: Function[];
scope_filter?: object;
frame_capacity?: number;
initial_buffer_size?: number;
input_ring_capacity?: number;
input_byte_size?: number;
reconcile_epsilon?: number;
time_dilation?: TimeDilation;
});
/** @type {NetworkPeer} */
peer: NetworkPeer;
/** @type {EntityComponentDataset} */
world: EntityComponentDataset;
/** @type {SimActionExecutor} */
executor: SimActionExecutor;
/** @type {ReplicationSlotTable} */
slot_table: ReplicationSlotTable;
/** @type {ActionLog} */
action_log: ActionLog;
/** @type {SimActionRegistry} */
action_registry: SimActionRegistry;
/**
* Per-frame input ring. Sized to hold roughly 2× RTT_in_frames of
* history so reconciliation can always find the inputs to replay.
* @type {InputRing}
*/
input_ring: InputRing;
/** @type {RewindEngine} */
rewind_engine: RewindEngine;
/**
* Tolerance for the no-op reconciliation short-circuit. If the user's
* {@link onComputeExpected} returns a state that matches the local
* state to within this epsilon, the rewind+replay is skipped.
* @type {number}
*/
reconcile_epsilon: number;
/** @type {number} */
reconcile_count: number;
/** @type {number} */
replay_frame_count: number;
/**
* Adaptive tick-rate controller. Server sends observed input-buffer
* depth via the TIME_DILATION packet; this maps it to a tick-period
* multiplier {@link dilation_factor}.
* @type {TimeDilation}
*/
time_dilation: TimeDilation;
/**
* Most recent buffer depth reported by the server. -1 before any
* feedback has arrived.
* @type {number}
*/
last_buffer_depth: number;
/**
* Tick-period multiplier from the latest TIME_DILATION feedback.
* `< 1.0` ⇒ caller should run faster, `> 1.0` ⇒ slower. 1.0 until
* the first feedback arrives.
*
* scheduled_tick_period_ms = TICK_PERIOD_MS * client.dilation_factor;
*
* @type {number}
*/
dilation_factor: number;
/**
* Fires once per tick during prediction. Args: `(frame_number,
* input_writer)` where `input_writer(write_fn)` records the input
* bytes into the InputRing. The user's handler should:
* 1. Sample input.
* 2. Write input bytes via `input_writer(buf => buf.writeXxx(...))`.
* 3. Execute SimAction(s) representing the input via
* `client.executor.execute(action)`.
* @type {Signal}
*/
onPredict: Signal;
/**
* Fires once per frame during reconciliation replay. Args:
* `(frame_number, input_reader)` where `input_reader` is a
* BinaryBuffer positioned at the start of that frame's stored
* input bytes (or `null` if the frame's input wasn't recorded —
* the handler must short-circuit in that case). The user's handler
* should re-execute the SAME SimAction(s) that the predict handler
* produced for the input.
* @type {Signal}
*/
onReplay: Signal;
/**
* Fires when AUTH_STATE arrives, BEFORE the rewind+replay is
* decided. Args: `(server_frame, network_id, buffer)`.
*
* The handler should peek at the server's bytes (the buffer is
* positioned at the start of the auth payload; the handler may
* advance and restore `buffer.position`) and compute what the
* client's predicted state at the *current* frame WOULD be if a
* full rewind+replay were performed, returning a 1D scalar or
* `null` to skip the short-circuit. The orchestrator compares
* the returned value to {@link onMeasureCurrent}'s return value
* and short-circuits the rewind if they match within
* {@link reconcile_epsilon}.
*
* Game code that doesn't want the optimization can simply not
* subscribe to this; the rewind+replay then runs unconditionally.
*
* @type {Signal}
*/
onComputeExpected: Signal;
/**
* Fires alongside {@link onComputeExpected} to measure the current
* local state for comparison. Args: `(network_id)`. Returns a
* scalar.
* @type {Signal}
*/
onMeasureCurrent: Signal;
/**
* Fires inside __handle_auth_state AFTER the no-op short-circuit
* decided a rewind is needed but BEFORE the rewind starts.
* Args: `(server_frame, network_id)`. Used by higher-level wiring
* (e.g. {@link NetworkSession}) to bring the live world into
* canonical form before `RewindEngine.rewind_to` reads it.
* @type {Signal}
*/
onBeforeReconcile: Signal;
/**
* Fires on AUTH_STATE arrival, after the no-op check decided to
* actually do the rewind+replay. Args: `(server_frame,
* network_id, buffer)` where buffer is positioned at the auth
* payload bytes. The handler should deserialize the bytes into
* the world (the rewind has already happened; the buffer's bytes
* ARE the new authoritative state at server_frame).
* @type {Signal}
*/
onApplyAuthState: Signal;
/**
* Fires after each completed reconciliation (rewind + apply + replay).
* Diagnostic. Args: `(server_frame, replay_count)`.
* @type {Signal}
*/
onReconcileComplete: Signal;
/**
* Connect to a server peer over a transport.
* @param {number} peer_id
* @param {object} transport
*/
connect_to_server(peer_id: number, transport: object): void;
/**
* Disconnect from a server peer.
* @param {number} peer_id
*/
disconnect_from_server(peer_id: number): void;
/** Channel accessor for manual packet plumbing.
* @param {number} peer_id */
channel_for(peer_id: number): import("../transport/Channel.js").Channel;
/**
* Run one client-side prediction tick.
*
* 1. Open action_log frame.
* 2. Fire {@link onPredict} so the user samples input, records it in the
* InputRing, and executes SimAction(s).
* 3. Close action_log frame (this flushes the action stream to all
* connected peers via `peer.end_tick`).
*
* @param {number} current_frame
*/
tick(current_frame: number): void;
/**
* Whether the InputRing has stored input bytes for the given frame.
* Useful in {@link onReplay} handlers that need to short-circuit
* frames with no recorded input.
*
* @param {number} frame
* @returns {boolean}
*/
has_input_for(frame: number): boolean;
/**
* Read-only access to the InputRing buffer for a given frame. Position
* is reset to 0 by the call.
*
* @param {number} frame
* @returns {BinaryBuffer}
*/
input_for(frame: number): BinaryBuffer;
/**
* Helper for the {@link onComputeExpected} handler: stash the
* computed scalar so the orchestrator can read it. Called inside the
* handler.
* @param {number} value
*/
set_expected(value: number): void;
/**
* Helper for the {@link onMeasureCurrent} handler: stash the measured
* scalar so the orchestrator can read it. Called inside the handler.
* @param {number} value
*/
set_measured(value: number): void;
#private;
}
import { NetworkPeer } from "./NetworkPeer.js";
import { InputRing } from "../state/InputRing.js";
import { RewindEngine } from "../sim/RewindEngine.js";
import { TimeDilation } from "../time/TimeDilation.js";
import Signal from "../../../core/events/signal/Signal.js";
//# sourceMappingURL=ServerAuthoritativeClient.d.ts.map