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