UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

260 lines 10.5 kB
/** * 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