@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
217 lines • 9.28 kB
TypeScript
/**
* 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 {
/**
* @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, frame_capacity, initial_buffer_size, simulation_delay_ticks, }: {
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;
});
/** @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;
/** @type {ReplicatedComponentRegistry} */
component_registry: ReplicatedComponentRegistry;
/** @type {RewindEngine} */
rewind_engine: RewindEngine;
/**
* 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}
*/
last_client_frame_processed: number;
/**
* Number of ticks the server's simulation lags behind the caller's
* wall frame. See constructor doc.
* @type {number}
*/
simulation_delay_ticks: number;
/**
* Most recently simulated frame. `-1` during warmup
* (`wall_frame < simulation_delay_ticks`), advances by 1 per
* `tick()` after.
* @type {number}
*/
current_sim_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}
*/
onLocalSim: 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}
*/
onTickComplete: 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}
*/
onRewind: 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: number, transport: object): void;
/**
* Disconnect a previously-connected peer.
* @param {number} peer_id
*/
disconnect_peer(peer_id: number): void;
/**
* 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: number, frame_number: number, network_id: number, write_fn: (buf: BinaryBuffer) => void): void;
/**
* 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: number): import("../transport/Channel.js").Channel;
/**
* 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: number): void;
/**
* 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: number): void;
/**
* 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: number): number;
#private;
}
import { NetworkPeer } from "./NetworkPeer.js";
import { RewindEngine } from "../sim/RewindEngine.js";
import Signal from "../../../core/events/signal/Signal.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
//# sourceMappingURL=ServerAuthoritativeServer.d.ts.map