UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,108 lines (1,024 loc) 49.6 kB
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); } }