UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

563 lines (519 loc) 24.4 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; import { binarySearchHighIndex } from "../../../core/collection/array/binarySearchHighIndex.js"; import { fastArrayEquals } from "../../../core/collection/array/fastArrayEquals.js"; import Signal from "../../../core/events/signal/Signal.js"; import { number_compare_ascending } from "../../../core/primitives/numbers/number_compare_ascending.js"; import { Replicator } from "../replication/Replicator.js"; import { AlwaysRelevantScope } from "../replication/ScopeFilter.js"; import { RewindEngine } from "../sim/RewindEngine.js"; import { NetworkPeer } from "./NetworkPeer.js"; /** * Server-authoritative orchestrator with deterministic per-tick rollback. * * Encapsulates the algorithm previously inlined in * `app/src/prototype/network_prototype_prediction.js`: * * 1. Inbound action packets do NOT execute on arrival. Instead, a deferral * hook on the {@link Replicator} drops them into a per-tick pending log * tagged with `(client_frame, sender_id, type_id, payload_bytes)`. * 2. Once per server tick, `tick(current_frame)` consumes the pending log: * - Rejects entries older than the action_log's rewindable window. * - Determines the oldest pending frame `replay_start`. * - Rewinds the server world back to end-of-(replay_start - 1) via the * action_log's prior-state captures (see {@link RewindEngine}). * - Replays forward `[replay_start, current_frame]`. For each frame `f`: * - Read historical actions out of `action_log[f]` BEFORE * `begin_frame(f)` recycles the buffer. * - Append pending entries for `f`. * - Stable-sort the merged list by `sender_id` (so multi-client order * is deterministic across peers — relies on per-record sender_id in * the action log). * - `executor.execute` each in sorted order, then run the user- * supplied local sim via {@link onLocalSim}. * - Clear pending. * * Net effect: a client action tagged at client tick K lands on the server as * if applied against end-of-K-1 server state, regardless of arrival timing * or order. Both peers compute identical world states for identical inputs. * * The user wires this orchestrator up by: * 1. Constructing with a world, replication setup, and (typically) an * {@link OwnerAwareScope} filter so client-owned-entity actions are * not echoed back. * 2. Calling {@link connect_peer} for each connected client. * 3. Subscribing to {@link onLocalSim} for per-frame server-side game logic * (e.g. clamps, physics, collision) and {@link onTickComplete} for * end-of-tick work (e.g. sending AUTH_STATE). * 4. Calling {@link tick} once per server frame. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class ServerAuthoritativeServer { #pending_frames; #pending_senders; #pending_type_ids; #pending_payloads; #pending_referenced_frames; #peer_max_received_frame; #historical_scratch; #sort_scratch; #payload_buf; /** * @param {{ * world: EntityComponentDataset, * binary_registry: BinarySerializationRegistry, * replicated_components: Function[], * action_classes: Function[], * scope_filter?: object, * frame_capacity?: number, * initial_buffer_size?: number, * simulation_delay_ticks?: number, * }} options * * `simulation_delay_ticks`: server-side input buffer (Overwatch-style). * When > 0, {@link tick}(wall_frame) simulates * `sim_frame = wall_frame - simulation_delay_ticks`. Pending actions * tagged for `> sim_frame` stay queued and apply when `sim_frame` * catches up — most client actions land before the server needs * them, so rollback rarely fires. Costs N ticks of perceived * input-to-feedback latency (client prediction hides it). Default 0 * reproduces pre-feature behavior. */ constructor({ world, binary_registry, replicated_components, action_classes, scope_filter = new AlwaysRelevantScope(), frame_capacity = 32, initial_buffer_size = 1024, simulation_delay_ticks = 0, }) { assert.isNonNegativeInteger(simulation_delay_ticks, 'simulation_delay_ticks'); /** @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; /** @type {ReplicatedComponentRegistry} */ this.component_registry = this.peer.component_registry; /** @type {RewindEngine} */ this.rewind_engine = new RewindEngine({ action_log: this.action_log, world: this.world, component_registry: this.component_registry, }); /** * Highest client-tagged frame received (across all peers). * RECEIPT semantic — under `simulation_delay_ticks > 0` this * may be ahead of {@link current_sim_frame}. Tag AUTH_STATE * with `current_sim_frame`, not this. * @type {number} */ this.last_client_frame_processed = -1; /** * Number of ticks the server's simulation lags behind the caller's * wall frame. See constructor doc. * @type {number} */ this.simulation_delay_ticks = simulation_delay_ticks; /** * Most recently simulated frame. `-1` during warmup * (`wall_frame < simulation_delay_ticks`), advances by 1 per * `tick()` after. * @type {number} */ this.current_sim_frame = -1; // Per-tick pending input log. Parallel arrays (no per-action object // alloc) of newly-arrived action records, plus a sorted unique set // of the frames they reference. this.#pending_frames = /** @type {number[]} */ ([]); this.#pending_senders = /** @type {number[]} */ ([]); this.#pending_type_ids = /** @type {number[]} */ ([]); this.#pending_payloads = /** @type {Uint8Array[]} */ ([]); this.#pending_referenced_frames = /** @type {number[]} */ ([]); /** * Running watermark per peer: highest client-tagged frame received * from each peer. {@link buffer_depth_for_peer} reads this minus * {@link current_sim_frame} to drive time-dilation feedback. * @private @type {Map<number, number>} */ this.#peer_max_received_frame = new Map(); // Scratch for the replay loop. this.#historical_scratch = /** @type {Array<{type_id:number, sender_id:number, payload:Uint8Array}>} */ ([]); this.#sort_scratch = /** @type {Array<{sender_id:number, type_id:number, payload:Uint8Array}>} */ ([]); this.#payload_buf = new BinaryBuffer(); this.#payload_buf.setCapacity(64); // Switch the Replicator to deferred-execute mode: incoming actions // accumulate in pending until tick() drains them. this.peer.replicator.on_pending_action = (peer_id, frame_number, type_id, buf, off, len) => { const payload = new Uint8Array(len); payload.set(buf.raw_bytes.subarray(off, off + len)); this.#pending_add(frame_number, peer_id, type_id, payload); if (frame_number > this.last_client_frame_processed) { this.last_client_frame_processed = frame_number; } const prev = this.#peer_max_received_frame.get(peer_id); if (prev === undefined || frame_number > prev) { this.#peer_max_received_frame.set(peer_id, frame_number); } }; /** * Fires once per frame during the replay loop, AFTER all merged * actions for that frame have been applied and BEFORE the action_log * frame is closed. Use this for server-authoritative local sim * (clamps, collision, AI step, etc.). The current world state * reflects all inputs for this frame; the local sim should be * idempotent under repeated application (because it may run many * times under rollback). * * Args: `(frame_number)`. * @type {Signal} */ this.onLocalSim = new Signal(); /** * Fires after `tick()` completes (pending drained, all frames in the * replay window applied). Use for end-of-tick work: sending * AUTH_STATE, recording diagnostics, etc. * * Args: `(current_frame)`. * @type {Signal} */ this.onTickComplete = new Signal(); /** * Fired when a tick triggers an actual rewind (i.e. the replay * window includes already-committed frames). Args: * `(committed_top, replay_target, depth)` where depth is * `committed_top - replay_target` (number of frames undone). * * Diagnostic: counts rollbacks and characterizes how deep they * went, which directly maps to how late client actions arrived. * Tick paths that ran without a rewind (steady-state, no pending * for past frames) do NOT fire this. * @type {Signal} */ this.onRewind = new Signal(); } /** * Connect a client peer over a transport. Subsequent action packets from * this peer will be buffered in the pending log via the deferral hook * and consumed on the next `tick()`. * * @param {number} peer_id * @param {object} transport */ connect_peer(peer_id, transport) { this.peer.connect_peer(peer_id, transport); } /** * Disconnect a previously-connected peer. * @param {number} peer_id */ disconnect_peer(peer_id) { this.peer.disconnect_peer(peer_id); } /** * Forward to {@link NetworkPeer#send_auth_state}. Typically called from * an {@link onTickComplete} subscriber. * * @param {number} peer_id * @param {number} frame_number * @param {number} network_id * @param {(buf: BinaryBuffer) => void} write_fn */ send_auth_state(peer_id, frame_number, network_id, write_fn) { return this.peer.send_auth_state(peer_id, frame_number, network_id, write_fn); } /** * Channel accessor for the user's manual packet plumbing (e.g. sending * empty ACK packets in the prototype). Most callers won't need this. * @param {number} peer_id */ channel_for(peer_id) { return this.peer.channel_for(peer_id); } /** * Send the current buffer-depth observation for one peer to that peer. * Convenience wrapper around {@link NetworkPeer#send_time_dilation_feedback} * that pulls the depth from {@link buffer_depth_for_peer}. Typically * invoked once per tick (or every few ticks) from an * {@link onTickComplete} subscriber. * * @param {number} peer_id */ send_time_dilation_feedback(peer_id) { this.peer.send_time_dilation_feedback(peer_id, this.buffer_depth_for_peer(peer_id)); } /** * Drive the rollback flow for one tick. * * With `simulation_delay_ticks = 0` (default), simulates the caller's * `wall_frame` and drains pending. With `> 0`, simulates * `sim_frame = wall_frame - simulation_delay_ticks` and holds * `frame > sim_frame` pending until the sim catches up. Warmup * (`sim_frame < 0`) is a no-op; `onTickComplete` does not fire. * * `onTickComplete` fires with `sim_frame` (not `wall_frame`). * * @param {number} current_frame the caller's wall frame */ tick(current_frame) { assert.isNonNegativeInteger(current_frame, 'current_frame'); const sim_frame = current_frame - this.simulation_delay_ticks; if (sim_frame < 0) return; // warmup // 1. Drop pending entries outside the action_log's rewindable window. const window_oldest = Math.max(0, sim_frame - this.action_log.frame_capacity + 1); if (this.#pending_frames.length > 0 && this.#pending_referenced_frames[0] < window_oldest) { this.#pending_trim_to_window(window_oldest); } // 2. Pick the replay window. `__pending_referenced_frames` is // sorted ascending; entries `> sim_frame` stay buffered. let replay_start = sim_frame; const refs = this.#pending_referenced_frames; if (refs.length > 0 && refs[0] <= sim_frame) { replay_start = refs[0]; } // 3. Rewind only if the replay window covers committed frames. const committed_top = sim_frame - 1; if (replay_start <= committed_top) { this.rewind_engine.rewind_to(committed_top, replay_start - 1); this.onRewind.send3(committed_top, replay_start - 1, committed_top - (replay_start - 1)); } // 4. Replay [replay_start, sim_frame]. for (let f = replay_start; f <= sim_frame; f++) { this.#replay_frame(f); } // 5. Keep only `frame > sim_frame` pending (future buffer). this.#pending_filter_to_future(sim_frame); // 6. Publish sim_frame before flushing, so onTickComplete handlers // reading current_sim_frame see the just-advanced value. this.current_sim_frame = sim_frame; this.peer.flush_outbound(sim_frame); this.onTickComplete.send1(sim_frame); } /** * Current input-buffer depth for a peer: `max_received_frame - current_sim_frame`. * Positive ⇒ peer is sending ahead of sim (good); zero/negative ⇒ * peer is falling behind. Returns 0 if no inputs from this peer. * During warmup returns the count of received frames. * * @param {number} peer_id * @returns {number} */ buffer_depth_for_peer(peer_id) { const max = this.#peer_max_received_frame.get(peer_id); if (max === undefined) return 0; if (this.current_sim_frame < 0) return max + 1; return max - this.current_sim_frame; } /** @private */ #replay_frame(f) { this.#sort_scratch.length = 0; // Read historical actions out of action_log[f] BEFORE begin_frame(f) // recycles the buffer. The sender_id is recorded per-record (see // SimActionExecutor), so the stable sort below produces deterministic // order regardless of how history was originally interleaved. this.#read_historical(f, this.#historical_scratch); for (let i = 0; i < this.#historical_scratch.length; i++) { const h = this.#historical_scratch[i]; this.#sort_scratch.push({ sender_id: h.sender_id, type_id: h.type_id, payload: h.payload }); } // Append newly-arrived pending actions for this frame, deduplicating // against historical entries. Retransmissions are routine on the // wire — `Replicator.pack_for_peer` packs every frame in // [last_acked + 1, current_frame] each tick, so an action lives in // every outgoing packet for the round-trip duration of its ack. // The server receives each retransmission as a fresh pending entry; // without dedup, the rollback flow merges it with the historical // record left over from the previous apply and executes the action // twice (and three times, four times… until the ack arrives). // // Dedup key: (sender_id, type_id, payload bytes). Two actions are // "the same arrival" if all three match. (Same sender_id and // type_id but different payload = legitimately distinct actions // for the same frame, e.g. fire + move in the same tick.) for (let i = 0; i < this.#pending_frames.length; i++) { if (this.#pending_frames[i] !== f) continue; const p_sender = this.#pending_senders[i]; const p_type = this.#pending_type_ids[i]; const p_payload = this.#pending_payloads[i]; let is_dup = false; for (let j = 0; j < this.#historical_scratch.length; j++) { const h = this.#historical_scratch[j]; if (h.sender_id !== p_sender) continue; if (h.type_id !== p_type) continue; if (fastArrayEquals(h.payload, p_payload)) { is_dup = true; break; } } if (is_dup) continue; this.#sort_scratch.push({ sender_id: p_sender, type_id: p_type, payload: p_payload }); } // Stable sort by sender. Array.prototype.sort is stable since ES2019; // equal sender_ids preserve insertion order, with historical entries // appearing before newly-arrived pending entries for the same sender. this.#sort_scratch.sort((a, b) => a.sender_id - b.sender_id); // Open the frame, execute all, close. this.action_log.begin_frame(f); for (let i = 0; i < this.#sort_scratch.length; i++) { const s = this.#sort_scratch[i]; this.#apply_payload(s.type_id, s.sender_id, s.payload); } // Notify user code so server-side local sim runs at the right point // in the frame (after inputs applied, before frame closed). Idempotent // under repeated application across rollback replays. this.onLocalSim.send1(f); this.action_log.end_frame(); } /** @private */ #apply_payload(type_id, sender_id, payload) { const klass = this.action_registry.klass_for(type_id); if (klass === undefined) return; const action = this.action_registry.acquire(klass); try { if (this.#payload_buf.raw_bytes.length < payload.length) { this.#payload_buf.setCapacity(payload.length); } this.#payload_buf.position = 0; this.#payload_buf.writeBytes(payload, 0, payload.length); this.#payload_buf.position = 0; action.deserialize(this.#payload_buf); this.executor.execute(action, sender_id); } finally { this.action_registry.release(action); } } /** @private */ #read_historical(frame_number, out_array) { out_array.length = 0; if (!this.action_log.has_frame(frame_number)) return; const buf = this.action_log.buffer_for(frame_number); // sets position = 0 const end = this.action_log.write_end_for(frame_number); while (buf.position < end) { const prior_count = buf.readUintVar(); for (let i = 0; i < prior_count; i++) { buf.readUintVar(); // entity_id buf.readUint8(); // component_type_id const plen = buf.readUint32(); buf.position += plen; } const type_id = buf.readUint8(); const sender_id = buf.readUint8(); const payload_len = buf.readUint32(); const payload = new Uint8Array(payload_len); payload.set(buf.raw_bytes.subarray(buf.position, buf.position + payload_len)); buf.position += payload_len; out_array.push({ type_id, sender_id, payload }); } } /** @private */ #pending_add(frame_number, sender_id, type_id, payload) { // Dedup against other pending entries for the same frame. Multiple // retransmission packets routinely arrive in the same c2s.tick // window when ack RTT exceeds tick period; each carries the same // action records for the same frames. The __replay_frame dedup // catches retransmissions across server ticks (where historical // already has the record), but the SAME server tick can collect // multiple identical pending entries before historical exists. So // also dedup here, against everything already in pending for this // frame. Match on (sender_id, type_id, payload bytes) — distinct // payloads from the same sender at the same frame are legitimately // different actions (move + fire) and both flow through. for (let i = 0; i < this.#pending_frames.length; i++) { if (this.#pending_frames[i] !== frame_number) continue; if (this.#pending_senders[i] !== sender_id) continue; if (this.#pending_type_ids[i] !== type_id) continue; if (fastArrayEquals(this.#pending_payloads[i], payload)) return; // duplicate already in pending; drop } this.#pending_frames.push(frame_number); this.#pending_senders.push(sender_id); this.#pending_type_ids.push(type_id); this.#pending_payloads.push(payload); this.#track_referenced_frame(frame_number); } /** * Insert `frame_number` into the monotonically-sorted referenced-frames * index, leaving it untouched if already present. Single source of truth * for the three sites (per-add, post-trim, post-filter) that maintain * this index, so the binary-search invariant and uniqueness check live * in one place. * @param {number} frame_number * @private */ #track_referenced_frame(frame_number) { const arr = this.#pending_referenced_frames; const i = binarySearchHighIndex(arr, frame_number, number_compare_ascending); if (arr[i] !== frame_number) arr.splice(i, 0, frame_number); } /** @private */ #pending_trim_to_window(window_oldest) { let kept = 0; for (let i = 0; i < this.#pending_frames.length; i++) { if (this.#pending_frames[i] >= window_oldest) { this.#pending_frames[kept] = this.#pending_frames[i]; this.#pending_senders[kept] = this.#pending_senders[i]; this.#pending_type_ids[kept] = this.#pending_type_ids[i]; this.#pending_payloads[kept] = this.#pending_payloads[i]; kept++; } } this.#pending_frames.length = kept; this.#pending_senders.length = kept; this.#pending_type_ids.length = kept; this.#pending_payloads.length = kept; this.#pending_referenced_frames.length = 0; for (const f of this.#pending_frames) { this.#track_referenced_frame(f); } } /** @private */ #pending_clear() { this.#pending_frames.length = 0; this.#pending_senders.length = 0; this.#pending_type_ids.length = 0; this.#pending_payloads.length = 0; this.#pending_referenced_frames.length = 0; } /** * Drop pending entries with `frame <= keep_threshold`, keeping the * rest. Same structural pattern as {@link __pending_trim_to_window} * but with the inequality flipped — used at the end of {@link tick} * to retain future-tagged inputs under `simulation_delay_ticks > 0`. * @private */ #pending_filter_to_future(keep_threshold) { let kept = 0; for (let i = 0; i < this.#pending_frames.length; i++) { if (this.#pending_frames[i] > keep_threshold) { this.#pending_frames[kept] = this.#pending_frames[i]; this.#pending_senders[kept] = this.#pending_senders[i]; this.#pending_type_ids[kept] = this.#pending_type_ids[i]; this.#pending_payloads[kept] = this.#pending_payloads[i]; kept++; } } this.#pending_frames.length = kept; this.#pending_senders.length = kept; this.#pending_type_ids.length = kept; this.#pending_payloads.length = kept; this.#pending_referenced_frames.length = 0; for (const f of this.#pending_frames) { this.#track_referenced_frame(f); } } }