@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
426 lines (385 loc) • 16.1 kB
JavaScript
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;
}
}