@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
335 lines (300 loc) • 13.8 kB
JavaScript
import { assert } from "../../../core/assert.js";
import Signal from "../../../core/events/signal/Signal.js";
/**
* Forwards action records between peers.
*
* **Send path** ({@link pack_for_peer}): walk the local action log over a
* frame range, filter records by scope, and write a packet that contains
* only the action portion of each record (no prior state — receivers don't
* need it; their executors capture their own prior state).
*
* **Receive path** ({@link unpack_from_peer}): parse the packet, deserialize
* each action via the registry, and run it through the local executor. The
* executor logs it into the receiver's action log just like locally-originated
* actions, capturing the receiver's prior state for its own rewind purposes.
*
* The Replicator is transport-agnostic: it produces and consumes
* `BinaryBuffer`s. The orchestrator hands the produced buffer to a transport
* and feeds the inbound buffer in the other direction.
*
* Wire format:
* ```
* loop while bytes remain:
* varint: frame_number
* varint: action_count
* loop action_count times:
* uint8: action_type_id
* uint32: action_payload_len
* bytes: action_payload
* ```
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class Replicator {
#record_bounds;
/**
* @param {{
* action_log: ActionLog,
* action_registry: SimActionRegistry,
* executor: SimActionExecutor,
* slot_table: ReplicationSlotTable,
* scope_filter: { is_entity_in_scope(peer_id: number, network_id: number): boolean },
* }} options
*/
constructor({ action_log, action_registry, executor, slot_table, scope_filter }) {
/**
* @type {ActionLog}
*/
this.action_log = action_log;
/**
* @type {SimActionRegistry}
*/
this.action_registry = action_registry;
/**
* @type {SimActionExecutor}
*/
this.executor = executor;
/**
* @type {ReplicationSlotTable}
*/
this.slot_table = slot_table;
/**
* Receives `network_id` (peer-shared) — NOT local `entity_id`. Replicator
* translates via `slot_table.network_for(entity_id)` before calling.
* @type {{ is_entity_in_scope(peer_id: number, network_id: number): boolean }}
*/
this.scope_filter = scope_filter;
/**
* Pre-allocated scratch for record positions in the current pack pass.
* Each slot holds (record_start, record_end) interleaved.
* Grown on demand.
* @type {Int32Array}
* @private
*/
this.#record_bounds = new Int32Array(64 * 2);
/**
* Fired after each per-frame action group is fully applied via
* {@link unpack_from_peer}. Args: `(peer_id, frame_number)`.
*
* Subscribe to drive interpolation buffers, replay logs, diagnostics,
* or anything else that needs to know "frame N from peer P has just
* been applied to the local world."
*
* Fires in frame-ascending order within a single packet (the order
* the wire format encodes them in).
*
* @type {Signal}
*/
this.onFrameApplied = new Signal();
/**
* Optional deferral hook. When non-null, {@link unpack_from_peer}
* does NOT touch the action log or executor — it parses the packet
* and calls this function once per action with:
*
* `(peer_id, frame_number, action_type_id, in_buffer, payload_offset, payload_len)`
*
* The buffer is shared scratch; consumers must copy the payload bytes
* if they need them to outlive the call. The orchestrator is then
* responsible for executing the buffered actions in whatever order
* the netcode model requires (e.g. rewind to oldest frame, stable-
* sort by sender, replay forward).
*
* Leave null for "execute on arrival" semantics (the default and
* what the loopback/single-orchestrator tests rely on).
*
* @type {((peer_id: number, frame_number: number, action_type_id: number, in_buffer: BinaryBuffer, payload_offset: number, payload_len: number) => void) | null}
*/
this.on_pending_action = null;
}
/**
* Pack actions from frames in `[start_frame, end_frame]` (inclusive) into
* `out_buffer` for a given peer, applying the scope filter.
*
* Writes nothing if no in-scope actions are found.
*
* @param {number} peer_id
* @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped
* @param {number} end_frame inclusive
* @param {BinaryBuffer} out_buffer
*/
pack_for_peer(peer_id, start_frame, end_frame, out_buffer) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(start_frame, 'start_frame');
assert.isNonNegativeInteger(end_frame, 'end_frame');
for (let frame = start_frame; frame <= end_frame; frame++) {
if (!this.action_log.has_frame(frame)) continue;
const buffer = this.action_log.buffer_for(frame);
const end = this.action_log.write_end_for(frame);
// First pass: walk records, capture (start, end) for those in scope.
let in_scope_count = 0;
while (buffer.position < end) {
const record_start = buffer.position;
const in_scope = this.#scan_record_in_scope(buffer, peer_id);
const record_end = buffer.position;
if (in_scope) {
if ((in_scope_count + 1) * 2 > this.#record_bounds.length) {
this.#grow_record_bounds();
}
this.#record_bounds[in_scope_count * 2] = record_start;
this.#record_bounds[in_scope_count * 2 + 1] = record_end;
in_scope_count++;
}
}
if (in_scope_count === 0) continue;
// Second pass: write the frame header + each in-scope action's payload only.
out_buffer.writeUintVar(frame);
out_buffer.writeUintVar(in_scope_count);
for (let i = 0; i < in_scope_count; i++) {
const rs = this.#record_bounds[i * 2];
buffer.position = rs;
this.#copy_action_to(buffer, out_buffer);
}
}
}
/**
* Parse and apply actions from `in_buffer` into the local world via the executor.
* Each frame in the packet opens a new entry in the local action log under the
* sender's frame number, ensuring the rewind/replay machinery sees consistent
* frame indexing across peers.
*
* @param {number} peer_id
* @param {BinaryBuffer} in_buffer
* @param {number} in_buffer_end byte position to stop reading at
*/
unpack_from_peer(peer_id, in_buffer, in_buffer_end) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(in_buffer_end, 'in_buffer_end');
// Deferral path: parse only, hand each action off to the orchestrator's
// pending-input log. The action log and executor are not touched here —
// the orchestrator decides when (and in what order) to apply actions
// (e.g. rewind to oldest pending frame and replay forward with stable-
// sort tie-breaking by sender, the way rollback netcode demands).
if (this.on_pending_action !== null) {
while (in_buffer.position < in_buffer_end) {
const frame_number = in_buffer.readUintVar();
const action_count = in_buffer.readUintVar();
for (let i = 0; i < action_count; i++) {
const action_type_id = in_buffer.readUint8();
const action_payload_len = in_buffer.readUint32();
this.on_pending_action(
peer_id, frame_number, action_type_id,
in_buffer, in_buffer.position, action_payload_len,
);
in_buffer.position += action_payload_len;
}
}
return;
}
const registry = this.action_registry;
while (in_buffer.position < in_buffer_end) {
const frame_number = in_buffer.readUintVar();
const action_count = in_buffer.readUintVar();
this.action_log.begin_frame(frame_number);
try {
for (let i = 0; i < action_count; i++) {
const action_type_id = in_buffer.readUint8();
const action_payload_len = in_buffer.readUint32();
const klass = registry.klass_for(action_type_id);
if (klass === undefined) {
// Unknown action — skip the payload to stay aligned and continue.
in_buffer.position += action_payload_len;
continue;
}
const action = registry.acquire(klass);
try {
action.deserialize(in_buffer);
// Sender of the action is the peer we received it from
// (the wire format strips sender_id for security).
this.executor.execute(action, peer_id);
} finally {
registry.release(action);
}
}
} finally {
this.action_log.end_frame();
}
// Fire AFTER end_frame so handlers see consistent state and can
// safely query the action log for the just-applied frame.
this.onFrameApplied.send2(peer_id, frame_number);
}
}
/**
* Walk a single record from the buffer's current position, advancing past it.
* Returns true if any of the record's affected entities is in scope for `peer_id`,
* or if the record has no affected entities (event-style action — always sent).
*
* @param {BinaryBuffer} buffer
* @param {number} peer_id
* @returns {boolean}
* @private
*/
#scan_record_in_scope(buffer, peer_id) {
const prior_count = buffer.readUintVar();
let any_in_scope = false;
for (let i = 0; i < prior_count; i++) {
const entity_id = buffer.readUintVar();
// Translate local entity_id to network_id for the scope filter, which
// operates on peer-shared identifiers. If `network_for` returns -1,
// the entity is not currently networked on this sender — either it
// was never replicated (local-only) or its slot was freed. In both
// cases the receiver cannot reconstruct the reference (no slot maps
// to it), so we skip rather than send a packet the receiver can't
// safely apply.
const network_id = this.slot_table.network_for(entity_id);
if (network_id >= 0 && this.scope_filter.is_entity_in_scope(peer_id, network_id)) {
any_in_scope = true;
}
buffer.readUint8(); // component_type_id
const payload_len = buffer.readUint32();
buffer.position += payload_len; // skip prior payload
}
// Skip the action payload too.
buffer.readUint8(); // action_type_id
buffer.readUint8(); // sender_id (local-only metadata, not sent)
const action_payload_len = buffer.readUint32();
buffer.position += action_payload_len;
// Event-style actions (no affected components) are always in scope.
return prior_count === 0 ? true : any_in_scope;
}
/**
* Buffer's current position is the start of an action record. Skip the
* prior_state section, then copy the action portion (type_id + len + payload)
* to `out_buffer`. Buffer position advances to the end of the record.
*
* @param {BinaryBuffer} buffer source action-log frame
* @param {BinaryBuffer} out_buffer destination packet
* @private
*/
#copy_action_to(buffer, out_buffer) {
// Skip prior_state.
const prior_count = buffer.readUintVar();
for (let i = 0; i < prior_count; i++) {
buffer.readUintVar(); // entity_id
buffer.readUint8(); // component_type_id
const len = buffer.readUint32();
buffer.position += len; // skip prior payload
}
// Copy action portion: type_id + len + payload. The local-only
// sender_id byte is STRIPPED here — over-the-wire format stays
// (type_id, len, payload) and the receiver derives sender from
// the packet's peer_id. Including sender_id on the wire would
// let a hostile peer impersonate other peers' actions.
const action_type_id = buffer.readUint8();
buffer.readUint8(); // sender_id — discarded, not sent on the wire
const action_payload_len = buffer.readUint32();
out_buffer.writeUint8(action_type_id);
out_buffer.writeUint32(action_payload_len);
out_buffer.writeBytes(buffer.raw_bytes, buffer.position, action_payload_len);
buffer.position += action_payload_len;
}
/**
* @private
*/
#grow_record_bounds() {
const old = this.#record_bounds;
const next = new Int32Array(old.length * 2);
next.set(old);
this.#record_bounds = next;
}
}