UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

426 lines (385 loc) 16.1 kB
import { assert } from "../../../core/assert.js"; import Signal from "../../../core/events/signal/Signal.js"; import { AlwaysRelevantScope } from "../replication/ScopeFilter.js"; import { RewindEngine } from "../sim/RewindEngine.js"; import { InputRing } from "../state/InputRing.js"; import { TimeDilation } from "../time/TimeDilation.js"; import { NetworkPeer } from "./NetworkPeer.js"; /** * 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 { #last_reconciled_frame; #current_frame; #expected_scratch; #measured_scratch; /** * @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 = new AlwaysRelevantScope(), frame_capacity = 32, initial_buffer_size = 1024, input_ring_capacity = 256, input_byte_size = 4, reconcile_epsilon = 1e-4, time_dilation = new TimeDilation(), }) { /** @type {NetworkPeer} */ this.peer = new NetworkPeer({ world, binary_registry, replicated_components, action_classes, scope_filter, frame_capacity, initial_buffer_size, }); /** @type {EntityComponentDataset} */ this.world = world; /** @type {SimActionExecutor} */ this.executor = this.peer.executor; /** @type {ReplicationSlotTable} */ this.slot_table = this.peer.slot_table; /** @type {ActionLog} */ this.action_log = this.peer.action_log; /** @type {SimActionRegistry} */ this.action_registry = this.peer.action_registry; /** * 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} */ this.input_ring = new InputRing({ frame_capacity: input_ring_capacity, initial_buffer_size: input_byte_size, }); /** @type {RewindEngine} */ this.rewind_engine = new RewindEngine({ action_log: this.action_log, world: this.world, component_registry: this.peer.component_registry, }); /** * 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} */ this.reconcile_epsilon = reconcile_epsilon; // State this.#last_reconciled_frame = -1; this.#current_frame = -1; // Counters /** @type {number} */ this.reconcile_count = 0; /** @type {number} */ this.replay_frame_count = 0; /** * 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} */ this.time_dilation = time_dilation; /** * Most recent buffer depth reported by the server. -1 before any * feedback has arrived. * @type {number} */ this.last_buffer_depth = -1; /** * 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} */ this.dilation_factor = 1.0; this.peer.onTimeDilationFeedback.add((_peer_id, buffer_depth) => { this.last_buffer_depth = buffer_depth; this.dilation_factor = this.time_dilation.compute(buffer_depth); }); /** * 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} */ this.onPredict = new 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} */ this.onReplay = new 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} */ this.onComputeExpected = new Signal(); /** * Fires alongside {@link onComputeExpected} to measure the current * local state for comparison. Args: `(network_id)`. Returns a * scalar. * @type {Signal} */ this.onMeasureCurrent = new 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} */ this.onBeforeReconcile = new 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} */ this.onApplyAuthState = new Signal(); /** * Fires after each completed reconciliation (rewind + apply + replay). * Diagnostic. Args: `(server_frame, replay_count)`. * @type {Signal} */ this.onReconcileComplete = new Signal(); // Wire the AUTH_STATE handler to the algorithm above. this.peer.onAuthState.add((_peer_id, server_frame, network_id, buffer) => { this.#handle_auth_state(server_frame, network_id, buffer); }); } /** * Connect to a server peer over a transport. * @param {number} peer_id * @param {object} transport */ connect_to_server(peer_id, transport) { this.peer.connect_peer(peer_id, transport); } /** * Disconnect from a server peer. * @param {number} peer_id */ disconnect_from_server(peer_id) { this.peer.disconnect_peer(peer_id); } /** Channel accessor for manual packet plumbing. * @param {number} peer_id */ channel_for(peer_id) { return this.peer.channel_for(peer_id); } /** * 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) { assert.isNonNegativeInteger(current_frame, 'current_frame'); this.#current_frame = current_frame; this.peer.begin_tick(current_frame); try { const input_writer = (cb) => this.input_ring.write(current_frame, cb); this.onPredict.send2(current_frame, input_writer); } finally { this.peer.end_tick(); } } /** * 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) { return this.input_ring.has(frame); } /** * 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) { return this.input_ring.buffer_for(frame); } /** @private */ #handle_auth_state(server_frame, network_id, buffer) { // Out-of-order safety: never reconcile to an older frame than the // last one we already reconciled to. if (server_frame <= this.#last_reconciled_frame) return; this.#last_reconciled_frame = server_frame; const local = this.slot_table.entity_for(network_id); if (local < 0) return; // No-op short-circuit. If the user provided both // onComputeExpected and onMeasureCurrent, compare. If the values // match within reconcile_epsilon, skip the rewind entirely. if (this.onComputeExpected.hasHandlers() && this.onMeasureCurrent.hasHandlers()) { // Capture the buffer position so onComputeExpected can peek // without disturbing the actual apply step below. const peek_pos = buffer.position; let expected = null; this.onComputeExpected.send3(server_frame, network_id, buffer); buffer.position = peek_pos; // The handler can stash the computed value on the buffer or via // a side channel; here we use a sentinel attached to the signal // result. Simpler protocol: handler stashes value in // this.#expected_scratch. Let's do that. expected = this.#expected_scratch; this.#expected_scratch = null; let measured = null; this.onMeasureCurrent.send1(network_id); measured = this.#measured_scratch; this.#measured_scratch = null; if (expected !== null && measured !== null && Math.abs(expected - measured) < this.reconcile_epsilon) { return; // Matches — no rewind needed. } } this.onBeforeReconcile.send2(server_frame, network_id); // Real divergence: rewind, apply server's bytes, replay forward. const rewind_target = server_frame - 1; if (this.#current_frame > rewind_target) { try { this.rewind_engine.rewind_to(this.#current_frame, rewind_target); } catch (e) { // The action_log probably rolled past server_frame. The // server would normally back-fill us via STATE_BURST; for now // skip this reconciliation. return; } } // Apply the server's authoritative state bytes. The handler does the // adapter-specific deserialize. this.onApplyAuthState.send3(server_frame, network_id, buffer); // Replay forward from (server_frame + 1) through current_frame. let replay_count = 0; for (let f = server_frame + 1; f <= this.#current_frame; f++) { this.action_log.begin_frame(f); try { let input_reader = null; if (this.input_ring.has(f)) { input_reader = this.input_ring.buffer_for(f); input_reader.position = 0; } this.onReplay.send2(f, input_reader); } finally { this.action_log.end_frame(); } replay_count++; } this.reconcile_count++; this.replay_frame_count += replay_count; this.onReconcileComplete.send2(server_frame, replay_count); } /** * 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) { this.#expected_scratch = value; } /** * 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) { this.#measured_scratch = value; } }