@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
1,108 lines (1,024 loc) • 49.6 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
import Signal from "../../../core/events/signal/Signal.js";
import { Replicator } from "../replication/Replicator.js";
import { AlwaysRelevantScope } from "../replication/ScopeFilter.js";
import { ActionLog } from "../sim/ActionLog.js";
import { ReplicatedComponentRegistry } from "../sim/ReplicatedComponentRegistry.js";
import { SimActionExecutor } from "../sim/SimActionExecutor.js";
import { SimActionRegistry } from "../sim/SimActionRegistry.js";
import { snapshotter_emit } from "../sim/Snapshotter.js";
import { Baseline } from "../state/Baseline.js";
import { ChangedEntitySet } from "../state/ChangedEntitySet.js";
import { MutationLedger } from "../state/MutationLedger.js";
import { ReplicationSlotTable } from "../state/ReplicationSlotTable.js";
import { Channel } from "../transport/Channel.js";
import { resend_fragment_chunk, send_fragmented } from "../transport/fragments/fragment_send.js";
import { FragmentAssembler } from "../transport/fragments/FragmentAssembler.js";
import { FragmentRetention } from "../transport/fragments/FragmentRetention.js";
import { MAX_CHANNEL_PAYLOAD_BYTES } from "../transport/fragments/packet_size.js";
import { ReliableCommandPipeline } from "../transport/ReliableCommandPipeline.js";
// DISCONNECT packets carry a short UTF-8 reason label. The encoder/decoder
// pair are stateless after construction; sharing module-level singletons
// avoids allocating a fresh pair on every send/receive of this packet type.
const DISCONNECT_TEXT_ENCODER = new TextEncoder();
const DISCONNECT_TEXT_DECODER = new TextDecoder();
/**
* Wire packet types. The first byte of every NetworkPeer-routed payload picks
* the dispatch path. New types can be added without breaking the action stream
* because old peers will just see an unknown type and drop the packet.
*
* @readonly
* @enum {number}
*/
export const NetworkPacketType = {
ACTION_STREAM: 0,
RECOVERY_REQUEST: 1,
STATE_BURST: 2,
/**
* Server → client authoritative state for one entity, tagged with the
* server frame the state represents. Used by client-side prediction to
* trigger rewind+replay reconciliation: "your entity should have looked
* like this at frame F."
*/
AUTH_STATE: 3,
/**
* One chunk of a fragmented logical payload. Used transparently by the
* peer when an outgoing message exceeds the channel's single-packet
* capacity. The fragment header (uint16 message_id, uint8 chunk_index,
* uint8 total_chunks) follows this type byte; the chunk payload follows
* that. The reassembled bytes are re-dispatched through this same
* switch (so any of the other packet types can be carried fragmented).
*/
FRAGMENT: 4,
/**
* Reliable command. Carries a uintVar logical_seq followed by a
* caller-defined payload. Delivered at-least-once with sender-side
* retransmit and receiver-side dedup via {@link ReliableCommandPipeline}.
* Used for chat, level transitions, lobby state — anything where
* loss-tolerance of the action-stream model isn't acceptable.
*/
RELIABLE_COMMAND: 5,
/**
* Selective-retransmit request from the receiver of a fragmented
* message. Wire layout (after the leading type byte):
*
* uint16 message_id (LE)
* uint8 missing_count
* uint8 missing_indices[missing_count]
*
* The sender's {@link FragmentRetention} looks up the retained
* source bytes and re-emits the listed chunks via
* {@link resend_fragment_chunk}. Receiver drives NACK timing
* via {@link FragmentAssembler#service}.
*/
NACK_FRAGMENT: 6,
/**
* Server → client time-dilation feedback. Wire layout:
* int16 buffer_depth (LE)
*
* `buffer_depth = max_received_frame - current_sim_frame` from the
* server's POV. Client feeds it to {@link TimeDilation} and dilates
* its tick clock to keep the buffer near target.
*/
TIME_DILATION: 7,
/**
* Server → newly-connected peer initial-state snapshot. Wire layout:
* 16 bytes session_token (UUID v1, identifies this peer-session
* for later RESUME_HELLO authentication)
* varint frame_number (server sim_frame this snapshot represents)
* bytes payload (Snapshotter wire format)
*
* Typically exceeds MTU; fragment-send handles chunking.
*/
INITIAL_SYNC: 8,
/**
* Client → server reconnection request. Sent after a transport rebuild
* to claim continuity with a prior session record. Wire layout:
* uintVar local_peer_id
* uintVar last_acked_frame
* 16 bytes session_token (from a prior INITIAL_SYNC)
*/
RESUME_HELLO: 9,
/**
* Server → client: resume accepted. Empty payload — the action
* stream will resume from `last_acked + 1` on the next host tick.
*/
RESUME_ACCEPT: 10,
/**
* Server → client: resume rejected. Wire layout:
* uint8 reason_code (see {@link ResumeRejectReason})
*
* A fresh INITIAL_SYNC typically follows on the next host tick;
* the client drops unconfirmed predictions and accepts the resync.
*/
RESUME_REJECT: 11,
/**
* Either direction. Wire layout:
* uint8 reason_label_length
* bytes reason_label (UTF-8, opaque to engine)
*
* Client → server: lets the host free state immediately instead of
* waiting for the grace timer.
* Server → client: the host called `drop_peer(peer_id, reason)`;
* the client surfaces `onConnectionPermanentlyLost(reason)` and
* does NOT attempt reconnect.
*/
DISCONNECT: 12,
};
/**
* Reason codes for {@link NetworkPacketType.RESUME_REJECT}.
* @readonly @enum {number}
*/
export const ResumeRejectReason = Object.freeze({
UnknownPeer: 0,
GraceExpired: 1,
TokenMismatch: 2,
PeerIdCollision: 3,
});
/**
* Per-tick orchestrator that wires the action infrastructure to one or more
* peers over a Transport.
*
* Holds a single world (`EntityComponentDataset`) plus the action machinery
* (executor, replicator, action log). For each connected peer it owns a
* `Channel` and a `seq → frame_end` map so that ack notifications advance the
* `Baseline.last_acked(peer_id)` watermark.
*
* Use as both server (many connected peers) and client (one peer = the server).
* The asymmetry is in *who* connects — the orchestrator itself is symmetric.
*
* Lifecycle:
* 1. `connect_peer(peer_id, transport)` — wire a transport for a peer.
* 2. Per tick:
* - `begin_tick(frame_number)` — open the action log for this frame.
* - User code runs game logic, calling `executor.execute(action)` for each
* replicated state mutation. Inbound actions from peers also flow into
* the executor automatically (via the channel.onPayload wiring).
* - `end_tick()` — close the frame and dispatch outgoing packets to every peer.
* 3. `disconnect_peer(peer_id)` — release the channel + baseline state.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class NetworkPeer {
#changed_entities;
#peers;
#send_buffer;
#recv_buffer;
#fragment_scratch;
#next_fragment_message_id;
#tick_open;
#current_frame;
/**
* @param {{
* world: EntityComponentDataset,
* binary_registry: BinarySerializationRegistry,
* replicated_components: Function[],
* action_classes: Function[],
* scope_filter?: { is_entity_in_scope(peer_id: number, network_id: number): boolean },
* frame_capacity?: number,
* initial_buffer_size?: number,
* mutation_ledger?: MutationLedger|null,
* changed_set_capacity?: number,
* }} options
*/
constructor({
world,
binary_registry,
replicated_components,
action_classes,
scope_filter = new AlwaysRelevantScope(),
frame_capacity = 32,
initial_buffer_size = 1024,
mutation_ledger = null,
changed_set_capacity = 256,
}) {
assert.ok(world && typeof world.createEntity === 'function', 'world must be an EntityComponentDataset');
assert.ok(binary_registry, 'binary_registry required');
assert.isFunction(replicated_components.forEach, 'replicated_components must be iterable');
assert.isFunction(action_classes.forEach, 'action_classes must be iterable');
/** @type {EntityComponentDataset} */
this.world = world;
/** @type {ReplicatedComponentRegistry} */
this.component_registry = new ReplicatedComponentRegistry(binary_registry);
for (const klass of replicated_components) {
this.component_registry.register(klass);
}
/** @type {SimActionRegistry} */
this.action_registry = new SimActionRegistry();
for (const klass of action_classes) {
this.action_registry.register(klass);
}
/** @type {ReplicationSlotTable} */
this.slot_table = new ReplicationSlotTable();
/** @type {ActionLog} */
this.action_log = new ActionLog({ frame_capacity, initial_buffer_size });
/**
* Optional per-tick mutation tracker. When a `MutationLedger` is supplied,
* this peer records each tick's affected network IDs into a fresh
* `ChangedEntitySet`, then compacts the set into the ledger at `end_tick`.
* Server-side peers should provide a ledger so they can answer recovery
* queries. Receive-only client peers don't need one.
*
* @type {MutationLedger|null}
*/
this.mutation_ledger = mutation_ledger;
/**
* Per-tick "what mutated this tick" sink, used only when
* {@link mutation_ledger} is set. Cleared at the start of each tick;
* compacted into the ledger at end-of-tick.
* @type {ChangedEntitySet|null}
* @private
*/
this.#changed_entities = mutation_ledger === null
? null
: new ChangedEntitySet({ capacity: changed_set_capacity });
/** @type {SimActionExecutor} */
this.executor = new SimActionExecutor({
world,
action_log: this.action_log,
action_registry: this.action_registry,
component_registry: this.component_registry,
slot_table: this.slot_table,
changed_entities: this.#changed_entities,
});
/** @type {Replicator} */
this.replicator = new Replicator({
action_log: this.action_log,
action_registry: this.action_registry,
executor: this.executor,
slot_table: this.slot_table,
scope_filter,
});
/** @type {Baseline} */
this.baseline = new Baseline();
/**
* peer_id → { channel, transport, seq_to_frame_end, on_payload, on_acked }
* @type {Map<number, { channel: Channel, transport: Object, seq_to_frame_end: Map<number, number>, on_payload: Function, on_acked: Function }>}
* @private
*/
this.#peers = new Map();
/**
* Pre-allocated send buffer for `end_tick` packing.
* @type {BinaryBuffer}
* @private
*/
this.#send_buffer = new BinaryBuffer();
/**
* Reusable scratch buffer for wrapping inbound payloads as BinaryBuffers
* for the Replicator to consume. Reused per packet (sequential delivery).
* @type {BinaryBuffer}
* @private
*/
this.#recv_buffer = new BinaryBuffer();
/**
* Reusable scratch buffer for outgoing fragment packets. Sized for one
* channel-level packet (MTU minus channel header).
* @type {Uint8Array}
* @private
*/
this.#fragment_scratch = new Uint8Array(MAX_CHANNEL_PAYLOAD_BYTES);
/**
* Monotonically-increasing identifier for fragmented messages.
* Wraps at uint16. The receiver's FragmentAssembler keys reassembly
* state on this; collisions would only matter if more than 65536
* messages were in flight simultaneously, which is impossible
* under any realistic transport pressure.
* @type {number}
* @private
*/
this.#next_fragment_message_id = 0;
/** @type {boolean} @private */
this.#tick_open = false;
/**
* Fired when an inbound packet is a `RECOVERY_REQUEST` (peer is asking
* which entities mutated in a tick range so it can request their state).
* Args: `(peer_id, start_tick, end_tick)`. Subscriber typically responds
* via {@link send_state_burst_for_range}.
* @type {Signal}
*/
this.onRecoveryRequest = new Signal();
/**
* Fired when an inbound packet is a `STATE_BURST` carrying current state
* for a list of entities (response to a recovery request, or push).
* Args: `(peer_id, buffer, length)` where `buffer` is positioned just
* past the packet-type prefix and `length` is the byte count of the
* burst payload (not counting the prefix). Apply via
* {@link snapshotter_apply_to_existing}.
* @type {Signal}
*/
this.onStateBurst = new Signal();
/**
* Fired when an inbound packet is an `AUTH_STATE` (server's authoritative
* state for one entity, tagged with the frame the state represents).
* Args: `(peer_id, frame_number, network_id, buffer)` where `buffer` is
* positioned at the start of the entity's state payload. The handler
* is responsible for deserializing the bytes and triggering whatever
* reconciliation policy the application wants (snap, rewind+replay).
* @type {Signal}
*/
this.onAuthState = new Signal();
/**
* Fires on TIME_DILATION packet arrival.
* Args: `(peer_id, buffer_depth)` — signed int16.
* @type {Signal}
*/
this.onTimeDilationFeedback = new Signal();
/**
* Fires on INITIAL_SYNC packet arrival.
* Args: `(peer_id, session_token, frame_number, buffer, payload_end)`.
* `session_token` is a `Uint8Array(16)` (UUID v1 raw bytes); the
* buffer is positioned at the start of the Snapshotter payload.
* @type {Signal}
*/
this.onInitialSync = new Signal();
/**
* Fires on RESUME_HELLO arrival (host-only path in practice).
* Args: `(peer_id, local_peer_id, last_acked_frame, session_token)`.
* Subscriber decides whether to accept (call
* {@link send_resume_accept}) or reject (call
* {@link send_resume_reject}).
* @type {Signal}
*/
this.onResumeHello = new Signal();
/**
* Fires on RESUME_ACCEPT arrival (client-only in practice).
* Args: `(peer_id)`.
* @type {Signal}
*/
this.onResumeAccept = new Signal();
/**
* Fires on RESUME_REJECT arrival (client-only in practice).
* Args: `(peer_id, reason_code)` — see {@link ResumeRejectReason}.
* @type {Signal}
*/
this.onResumeReject = new Signal();
/**
* Fires on DISCONNECT arrival.
* Args: `(peer_id, reason_label)` — `reason_label` is a string,
* opaque to the engine.
* @type {Signal}
*/
this.onDisconnectPacket = new Signal();
/**
* Fired when a reliable command arrives from a peer (delivered via
* {@link ReliableCommandPipeline}, dedup'd against retransmits).
*
* Args: `(peer_id, buffer, payload_offset, payload_length)`. The
* buffer is the per-peer scratch and may be reused after the handler
* returns; consumers must copy the payload bytes if they need them
* to outlive the call.
*
* Used for chat, level transitions, lobby/room state — application-
* level messages that need at-least-once delivery semantics rather
* than the action stream's best-effort.
* @type {Signal}
*/
this.onReliableCommand = new Signal();
}
/**
* Wire `transport` to the peer identified by `peer_id`. Creates a Channel
* over the transport and connects ack/payload notifications to the local
* Baseline / Replicator.
*
* @param {number} peer_id
* @param {{ send(bytes: Uint8Array, length: number): void, onReceive: any }} transport
*/
connect_peer(peer_id, transport) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
if (this.#peers.has(peer_id)) {
throw new Error(`NetworkPeer.connect_peer: peer ${peer_id} already connected`);
}
const channel = new Channel({ transport });
const seq_to_frame_end = new Map();
const fragment_assembler = new FragmentAssembler();
const fragment_retention = new FragmentRetention();
const reliable_pipeline = new ReliableCommandPipeline({
channel,
packet_type: NetworkPacketType.RELIABLE_COMMAND,
});
// Closure captured once at connect time so the per-tick service
// call doesn't allocate. Builds a NACK_FRAGMENT packet inside
// the orchestrator's shared __send_buffer and ships it over the
// peer's channel. `indices[0..count)` is a reused internal
// scratch from the assembler — only valid for the call.
const on_nack = (message_id, indices, count) => {
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.NACK_FRAGMENT);
this.#send_buffer.writeUint16LE(message_id);
this.#send_buffer.writeUint8(count);
for (let i = 0; i < count; i++) {
this.#send_buffer.writeUint8(indices[i]);
}
channel.send(this.#send_buffer.raw_bytes, this.#send_buffer.position);
};
// Forward pipeline deliveries to the user-facing signal, attributing
// them to the originating peer.
reliable_pipeline.onCommand.add((buf, payload_offset, payload_length) => {
this.onReliableCommand.send4(peer_id, buf, payload_offset, payload_length);
});
// Recursive dispatch helper. Used for both incoming transport bytes
// AND for reassembled FRAGMENT payloads (so the same switch handles
// both transparent and fragmented delivery of any packet type).
const dispatch = (bytes, length) => {
if (length < 1) {
// malformed: no type byte
return;
}
this.#recv_buffer.position = 0;
this.#recv_buffer.writeBytes(bytes, 0, length);
this.#recv_buffer.position = 0;
const packet_type = this.#recv_buffer.readUint8();
switch (packet_type) {
case NetworkPacketType.ACTION_STREAM:
// The Replicator opens a frame on the action log per packet
// (one per sender frame).
this.replicator.unpack_from_peer(peer_id, this.#recv_buffer, length);
break;
case NetworkPacketType.RECOVERY_REQUEST: {
const start_tick = this.#recv_buffer.readUintVar();
const end_tick = this.#recv_buffer.readUintVar();
this.onRecoveryRequest.send3(peer_id, start_tick, end_tick);
break;
}
case NetworkPacketType.STATE_BURST:
this.onStateBurst.send3(peer_id, this.#recv_buffer, length);
break;
case NetworkPacketType.AUTH_STATE: {
const frame_number = this.#recv_buffer.readUintVar();
const network_id = this.#recv_buffer.readUintVar();
this.onAuthState.send4(peer_id, frame_number, network_id, this.#recv_buffer);
break;
}
case NetworkPacketType.FRAGMENT: {
// Fragment header layout (after the leading type byte):
// uint16 message_id (LE), uint8 chunk_index, uint8 total_chunks
const message_id = this.#recv_buffer.readUint16LE();
const chunk_index = this.#recv_buffer.readUint8();
const total_chunks = this.#recv_buffer.readUint8();
const chunk_offset = this.#recv_buffer.position;
const chunk_length = length - chunk_offset;
const reassembled = fragment_assembler.receive(
message_id, chunk_index, total_chunks,
bytes, chunk_offset, chunk_length,
);
if (reassembled !== null) {
// Defence-in-depth: don't allow nested fragmentation
// (would indicate a sender bug; ignore silently).
if (reassembled[0] !== NetworkPacketType.FRAGMENT) {
dispatch(reassembled, reassembled.length);
}
}
break;
}
case NetworkPacketType.NACK_FRAGMENT: {
// Receiver is asking for specific chunks of a
// previously-sent fragmented message.
// uint16 message_id (LE), uint8 missing_count,
// uint8 missing_indices[missing_count]
const message_id = this.#recv_buffer.readUint16LE();
const missing_count = this.#recv_buffer.readUint8();
const entry = fragment_retention.consume_nack(message_id);
if (entry === null) {
// Either we never retained this id (already evicted /
// never sent), or the retry budget for it is exhausted.
// Either way: nothing to retransmit. Drop silently.
break;
}
for (let i = 0; i < missing_count; i++) {
const chunk_index = this.#recv_buffer.readUint8();
resend_fragment_chunk(
entry.payload, entry.length, message_id, chunk_index,
NetworkPacketType.FRAGMENT, this.#fragment_scratch,
(chunk_bytes, chunk_length) => channel.send(chunk_bytes, chunk_length),
);
}
break;
}
case NetworkPacketType.RELIABLE_COMMAND:
// Pipeline reads logical_seq, dedups, fires its onCommand
// (which we forward to the user's onReliableCommand above).
reliable_pipeline.handle_inbound(this.#recv_buffer, length);
break;
case NetworkPacketType.TIME_DILATION: {
const depth = this.#recv_buffer.readInt16();
this.onTimeDilationFeedback.send2(peer_id, depth);
break;
}
case NetworkPacketType.INITIAL_SYNC: {
const session_token = new Uint8Array(16);
this.#recv_buffer.readBytes(session_token, 0, 16);
const frame_number = this.#recv_buffer.readUintVar();
this.onInitialSync.send5(peer_id, session_token, frame_number, this.#recv_buffer, length);
break;
}
case NetworkPacketType.RESUME_HELLO: {
const local_peer_id = this.#recv_buffer.readUintVar();
const last_acked_frame = this.#recv_buffer.readUintVar();
const session_token = new Uint8Array(16);
this.#recv_buffer.readBytes(session_token, 0, 16);
this.onResumeHello.send4(peer_id, local_peer_id, last_acked_frame, session_token);
break;
}
case NetworkPacketType.RESUME_ACCEPT: {
this.onResumeAccept.send1(peer_id);
break;
}
case NetworkPacketType.RESUME_REJECT: {
const reason_code = this.#recv_buffer.readUint8();
this.onResumeReject.send2(peer_id, reason_code);
break;
}
case NetworkPacketType.DISCONNECT: {
const label_length = this.#recv_buffer.readUint8();
let label = '';
if (label_length > 0) {
const label_bytes = new Uint8Array(label_length);
this.#recv_buffer.readBytes(label_bytes, 0, label_length);
label = DISCONNECT_TEXT_DECODER.decode(label_bytes);
}
this.onDisconnectPacket.send2(peer_id, label);
break;
}
default:
// Unknown type — drop. Forward-compat for newer peers.
break;
}
};
// Ack: advance Baseline to the highest acked frame_end for this peer.
const on_acked = (seq) => {
const frame_end = seq_to_frame_end.get(seq);
if (frame_end !== undefined) {
this.baseline.set_acked(peer_id, frame_end);
seq_to_frame_end.delete(seq);
}
};
channel.onPayload.add(dispatch);
channel.onPacketAcked.add(on_acked);
this.#peers.set(peer_id, {
channel, transport, seq_to_frame_end,
on_payload: dispatch, on_acked, on_nack,
fragment_assembler, fragment_retention,
reliable_pipeline,
});
}
/**
* Unwire and forget a peer.
* @param {number} peer_id
*/
disconnect_peer(peer_id) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
const conn = this.#peers.get(peer_id);
if (conn === undefined) return;
conn.channel.onPayload.remove(conn.on_payload);
conn.channel.onPacketAcked.remove(conn.on_acked);
// Drop the channel's subscription on the transport. Required for the
// common case of reusing the same transport on a subsequent reconnect:
// without this, the orphaned channel keeps consuming inbound packets
// and corrupts ack state in parallel with whatever channel replaces it.
conn.channel.dispose();
// Drop any in-flight fragment reassemblies for this peer — a fresh
// connection on the same peer_id should start clean.
conn.fragment_assembler.clear();
// Same for the sender-side retention buffer: nothing about the
// previous connection's retransmit history should leak across.
conn.fragment_retention.clear();
// Detach the reliable pipeline (drops its channel subscriptions and
// any unacked / received state).
conn.reliable_pipeline.dispose();
this.#peers.delete(peer_id);
this.baseline.forget(peer_id);
}
/**
* Whether `peer_id` is currently connected.
* @param {number} peer_id
* @returns {boolean}
*/
is_connected(peer_id) {
return this.#peers.has(peer_id);
}
/**
* Direct access to a peer's channel (for sending acks-only packets, raw payloads, etc.).
* @param {number} peer_id
* @returns {Channel|undefined}
*/
channel_for(peer_id) {
const conn = this.#peers.get(peer_id);
return conn === undefined ? undefined : conn.channel;
}
/**
* Open the action log for `frame_number`. Call before running game logic
* for the tick. The user's code (or the engine's `EntityManager.simulate`)
* runs between `begin_tick` and `end_tick`, calling `executor.execute()`
* for each replicated state mutation.
*
* @param {number} frame_number
*/
begin_tick(frame_number) {
assert.isNonNegativeInteger(frame_number, 'frame_number');
if (this.#tick_open) {
throw new Error(`NetworkPeer.begin_tick: previous tick still open; call end_tick first`);
}
// Reset the per-tick changed-set so this tick starts with a blank slate.
if (this.#changed_entities !== null) this.#changed_entities.clear();
this.action_log.begin_frame(frame_number);
this.#tick_open = true;
this.#current_frame = frame_number;
}
/**
* Close the current tick and send pending action records to every peer.
* For each peer, packs frames `[Baseline.last_acked(peer) + 1, current_frame]`
* and sends via the channel; records the seq→frame_end mapping so the next
* ack from that peer advances the baseline.
*
* If a {@link MutationLedger} was supplied at construction, this tick's
* accumulated changed-entity set is also compacted into the ledger.
*/
end_tick() {
if (!this.#tick_open) {
throw new Error(`NetworkPeer.end_tick: no tick is open; call begin_tick first`);
}
this.action_log.end_frame();
this.#tick_open = false;
this.flush_outbound(this.#current_frame);
// Per-tick fragment maintenance: age out sender-side retention
// and drive NACK emission for any incomplete inbound messages.
// Runs AFTER the action-stream send loop so the shared
// __send_buffer is free for the on_nack closure to reuse.
const now_ms = performance.now();
for (const [, conn] of this.#peers) {
conn.fragment_retention.service(now_ms);
conn.fragment_assembler.service(now_ms, conn.on_nack);
}
}
/**
* Pack and dispatch the outbound action stream for `current_frame` to
* every connected peer. Second half of {@link end_tick} — exposed
* separately so orchestrators that manage action_log frames directly
* (e.g. {@link ServerAuthoritativeServer}'s rollback flow, which
* begin_frame/end_frames many frames per tick) can drive the
* outbound dispatch without going through begin_tick/end_tick.
*
* Assumes the action_log frame for `current_frame` is already CLOSED
* by the caller. Records the tick's accumulated mutations into the
* MutationLedger (if configured), then packs frames
* `[last_acked+1, current_frame]` for each peer via
* `Replicator.pack_for_peer`. Empty packs (no in-scope actions) are
* not sent. Clears the per-tick changed-entity set after recording.
*
* @param {number} current_frame
*/
flush_outbound(current_frame) {
assert.isNonNegativeInteger(current_frame, 'current_frame');
// Record this tick's mutations into the persistent ledger BEFORE sending.
// (Order doesn't strictly matter for correctness, but keeping the ledger
// up-to-date as soon as possible reduces edge-case race windows for
// recovery requests that arrive immediately after.)
if (this.mutation_ledger !== null && this.#changed_entities !== null) {
this.mutation_ledger.record_tick(current_frame, this.#changed_entities);
}
for (const [peer_id, conn] of this.#peers) {
const last_acked = this.baseline.last_acked(peer_id);
// Pack-range start.
//
// For peers with at least one acked frame, this is just
// last_acked+1 — the standard "catch up since you last
// confirmed receipt."
//
// For freshly-connected peers (no ack yet), we cover the
// action_log's full ring: max(0, current_frame -
// frame_capacity + 1). Two competing constraints:
//
// - Don't pack `current_frame` only: under any non-zero
// latency, A's actions tagged at frame X land on the
// server in `action_log[X]`, not `action_log[current]`.
// A relay peer (B) whose start is `current` would see
// an empty pack every tick → no packet sent → B can't
// ack → baseline never advances → bootstrap deadlock.
// - Don't pack from frame 0: would waste bandwidth
// iterating frames the ring no longer holds. The `has_frame`
// guard inside pack_for_peer would skip them, but the
// loop bound itself can grow unboundedly with
// current_frame.
//
// `current_frame - frame_capacity + 1` is the oldest frame
// the ring could still hold, so we naturally cap at the
// ring's actual content. Actions referencing entities the
// peer's slot_table doesn't yet know about silently no-op
// (Replicator translates via network_for → -1; SimAction
// apply hits the `if (local < 0) return` guard). For first-
// connect scenarios the app is still expected to send a
// state-burst seed; this just makes the action-stream
// bootstrap work in addition to that.
const start = last_acked < 0
? Math.max(0, current_frame - this.action_log.frame_capacity + 1)
: last_acked + 1;
if (start > current_frame) continue;
this.#send_buffer.position = 0;
// Packet-type prefix.
this.#send_buffer.writeUint8(NetworkPacketType.ACTION_STREAM);
this.replicator.pack_for_peer(peer_id, start, current_frame, this.#send_buffer);
const length = this.#send_buffer.position;
// Just the prefix → no in-scope actions to send.
if (length === 1) continue;
const seq = this.#send_to_peer(conn, this.#send_buffer.raw_bytes, length);
conn.seq_to_frame_end.set(seq, current_frame);
}
// Reset the per-tick changed-entity set so the next tick starts clean.
// (begin_tick also clears it; the dual call is harmless.)
if (this.#changed_entities !== null) this.#changed_entities.clear();
}
/**
* Send a logical payload to a connected peer, fragmenting transparently
* if it exceeds the channel's single-packet capacity.
*
* Returns the seq of the FIRST transport packet emitted. For non-
* fragmented sends that's the only packet; for fragmented sends, that
* seq is what callers should record in `seq_to_frame_end` — when it
* acks, the first fragment arrived, which is a sufficient signal that
* the message is in flight. Fragments either all arrive (and the
* receiver reassembles) or the message is lost; the action-stream's
* back-fill mechanism naturally recovers from the latter on subsequent
* sends.
*
* @param {object} conn entry from __peers
* @param {Uint8Array} payload starts with the packet-type byte
* @param {number} length
* @returns {number} seq of the first packet sent
* @private
*/
#send_to_peer(conn, payload, length) {
if (length <= MAX_CHANNEL_PAYLOAD_BYTES) {
return conn.channel.send(payload, length);
}
const message_id = this.#next_fragment_message_id;
this.#next_fragment_message_id = (this.#next_fragment_message_id + 1) & 0xFFFF;
// Retain the source bytes so we can satisfy NACK retransmits.
// Copy happens inside retain(); `payload` is the orchestrator's
// shared send buffer and will be overwritten by the next call.
conn.fragment_retention.retain(message_id, payload, length, performance.now());
let first_seq = -1;
send_fragmented(
payload, length, message_id,
NetworkPacketType.FRAGMENT, this.#fragment_scratch,
(chunk_bytes, chunk_length) => {
const seq = conn.channel.send(chunk_bytes, chunk_length);
if (first_seq < 0) first_seq = seq;
},
);
return first_seq;
}
/**
* Send a recovery request to `peer_id`: "tell me the current state of every
* entity that mutated between `start_tick` and `end_tick`." The peer's
* server-side handler ({@link onRecoveryRequest}) is expected to respond by
* calling {@link send_state_burst_for_range}.
*
* Best-effort delivery via the same UDP-style channel — if the request is
* lost, the client is expected to retry.
*
* @param {number} peer_id
* @param {number} start_tick
* @param {number} end_tick
*/
send_recovery_request(peer_id, start_tick, end_tick) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(start_tick, 'start_tick');
assert.isNonNegativeInteger(end_tick, 'end_tick');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_recovery_request: peer ${peer_id} not connected`);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.RECOVERY_REQUEST);
this.#send_buffer.writeUintVar(start_tick);
this.#send_buffer.writeUintVar(end_tick);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Server-side recovery response. Looks up which entities mutated in
* `[start_tick, end_tick]` via the local `MutationLedger` (must be present),
* then sends the current state of those entities to `peer_id` via a
* `STATE_BURST` packet. Receiver applies via `snapshotter_apply_to_existing`.
*
* If no entities are in the range (or the range is older than the ledger
* retains), nothing is sent. The requester should treat absence of response
* after a timeout as "needs full re-init."
*
* @param {number} peer_id
* @param {number} start_tick
* @param {number} end_tick
*/
send_state_burst_for_range(peer_id, start_tick, end_tick) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(start_tick, 'start_tick');
assert.isNonNegativeInteger(end_tick, 'end_tick');
if (this.mutation_ledger === null) {
throw new Error('NetworkPeer.send_state_burst_for_range: requires mutation_ledger to be configured');
}
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_state_burst_for_range: peer ${peer_id} not connected`);
// Reuse the changed-set as a scratch dedupe surface for the union.
// It's been cleared at the start of this tick (or hasn't been used yet);
// it will be cleared again at the start of the next tick.
const scratch = this.#changed_entities !== null
? this.#changed_entities
: new ChangedEntitySet({ capacity: 1024 });
// Don't clobber an in-progress tick's accumulated mutations. If a tick
// is currently open we use a fresh set just for this query.
const set = this.#tick_open
? new ChangedEntitySet({ capacity: 1024 })
: scratch;
set.clear();
this.mutation_ledger.entities_changed_in_range(set, start_tick, end_tick);
if (set.size() === 0) return;
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.STATE_BURST);
snapshotter_emit({
buffer: this.#send_buffer,
world: this.world,
slot_table: this.slot_table,
component_registry: this.component_registry,
entity_iter: cb => {
set.for_each(network_id => {
const eid = this.slot_table.entity_for(network_id);
if (eid >= 0) cb(eid);
});
},
});
// STATE_BURST is the primary path that can exceed MTU — a recovery
// response covering many entities easily lands in the multi-KB range.
// The fragmenting wrapper splits it across packets transparently.
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send the authoritative state of one entity to `peer_id`, tagged with
* the frame the state represents. Used by client-side prediction: the
* server emits this every tick (or every N ticks) for client-owned
* entities; the client compares to its predicted state at the same frame
* and reconciles via rewind+replay if they differ.
*
* The payload is whatever `write_fn(buffer)` writes — typically a
* `BinaryClassSerializationAdapter.serialize(buffer, component)` call.
* The receiver's `onAuthState` handler reads the same bytes via the
* matching deserializer.
*
* @param {number} peer_id
* @param {number} frame_number sender's frame the state corresponds to
* @param {number} network_id which entity this state is for
* @param {function(BinaryBuffer): void} write_fn writes the entity's state bytes
*/
send_auth_state(peer_id, frame_number, network_id, write_fn) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(frame_number, 'frame_number');
assert.isNonNegativeInteger(network_id, 'network_id');
assert.isFunction(write_fn, 'write_fn');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_auth_state: peer ${peer_id} not connected`);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.AUTH_STATE);
this.#send_buffer.writeUintVar(frame_number);
this.#send_buffer.writeUintVar(network_id);
write_fn(this.#send_buffer);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send an INITIAL_SYNC packet. `write_fn` receives the send-buffer
* positioned just past the `(packet_type, session_token, frame_number)`
* header and writes the Snapshotter payload. Fragments transparently.
*
* @param {number} peer_id
* @param {Uint8Array} session_token 16-byte UUID v1 identifying this peer-session
* @param {number} frame_number server sim frame this snapshot represents
* @param {function(BinaryBuffer): void} write_fn
*/
send_initial_sync(peer_id, session_token, frame_number, write_fn) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.ok(session_token instanceof Uint8Array && session_token.length === 16,
'session_token must be a 16-byte Uint8Array');
assert.isNonNegativeInteger(frame_number, 'frame_number');
assert.isFunction(write_fn, 'write_fn');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_initial_sync: peer ${peer_id} not connected`);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.INITIAL_SYNC);
this.#send_buffer.writeBytes(session_token, 0, 16);
this.#send_buffer.writeUintVar(frame_number);
write_fn(this.#send_buffer);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send a RESUME_HELLO packet to the server. Client-only direction.
*
* @param {number} peer_id remote peer (the server)
* @param {number} local_peer_id the client's own peer id
* @param {number} last_acked_frame the client's view of last-acked frame
* @param {Uint8Array} session_token 16-byte token from a prior INITIAL_SYNC
*/
send_resume_hello(peer_id, local_peer_id, last_acked_frame, session_token) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(local_peer_id, 'local_peer_id');
assert.isNonNegativeInteger(last_acked_frame, 'last_acked_frame');
assert.ok(session_token instanceof Uint8Array && session_token.length === 16,
'session_token must be a 16-byte Uint8Array');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_resume_hello: peer ${peer_id} not connected`);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.RESUME_HELLO);
this.#send_buffer.writeUintVar(local_peer_id);
this.#send_buffer.writeUintVar(last_acked_frame);
this.#send_buffer.writeBytes(session_token, 0, 16);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send a RESUME_ACCEPT packet. Host-only direction.
* @param {number} peer_id
*/
send_resume_accept(peer_id) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_resume_accept: peer ${peer_id} not connected`);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.RESUME_ACCEPT);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send a RESUME_REJECT packet. Host-only direction.
* @param {number} peer_id
* @param {number} reason_code one of {@link ResumeRejectReason}
*/
send_resume_reject(peer_id, reason_code) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(reason_code, 'reason_code');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_resume_reject: peer ${peer_id} not connected`);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.RESUME_REJECT);
this.#send_buffer.writeUint8(reason_code & 0xFF);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send a DISCONNECT packet. Either direction. `reason_label` is an
* opaque string (255 byte cap after UTF-8 encoding).
*
* @param {number} peer_id
* @param {string} [reason_label]
*/
send_disconnect(peer_id, reason_label = '') {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isString(reason_label, 'reason_label');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_disconnect: peer ${peer_id} not connected`);
const encoded = DISCONNECT_TEXT_ENCODER.encode(reason_label);
const len = Math.min(encoded.length, 255);
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.DISCONNECT);
this.#send_buffer.writeUint8(len);
if (len > 0) this.#send_buffer.writeBytes(encoded, 0, len);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send a TIME_DILATION feedback packet. `buffer_depth` is clamped
* to the int16 range — saturating is fine since the controller
* doesn't need sub-int16 precision.
*
* @param {number} peer_id
* @param {number} buffer_depth signed integer; clamped to int16
*/
send_time_dilation_feedback(peer_id, buffer_depth) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNumber(buffer_depth, 'buffer_depth');
const conn = this.#peers.get(peer_id);
if (conn === undefined) throw new Error(`NetworkPeer.send_time_dilation_feedback: peer ${peer_id} not connected`);
let depth = buffer_depth | 0;
if (depth > 32767) depth = 32767;
else if (depth < -32768) depth = -32768;
this.#send_buffer.position = 0;
this.#send_buffer.writeUint8(NetworkPacketType.TIME_DILATION);
this.#send_buffer.writeInt16(depth);
this.#send_to_peer(conn, this.#send_buffer.raw_bytes, this.#send_buffer.position);
}
/**
* Send a reliable command to a connected peer. Delivered at-least-once
* with sender-side retransmit on Channel-detected loss and receiver-
* side dedup by logical seq. Use for messages where the action-stream
* model (best-effort, per-frame back-fill) isn't suitable — chat,
* lobby/room state, level transitions.
*
* Returns the logical seq the pipeline assigned (diagnostic only; the
* receiver dedups internally regardless).
*
* @param {number} peer_id
* @param {Uint8Array} payload
* @param {number} length
* @returns {number} logical seq
*/
send_reliable_command(peer_id, payload, length) {
assert.isNonNegativeInteger(peer_id, 'peer_id');
assert.isNonNegativeInteger(length, 'length');
const conn = this.#peers.get(peer_id);
if (conn === undefined) {
throw new Error(`NetworkPeer.send_reliable_command: peer ${peer_id} not connected`);
}
return conn.reliable_pipeline.send(payload, length);
}
}