@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
292 lines (266 loc) • 11.2 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
import Signal from "../../../core/events/signal/Signal.js";
/**
* Reliable, at-least-once delivery layer over a {@link Channel}.
*
* Used for messages that must arrive: chat, lobby/room state changes,
* level transitions, kick / disconnect notifications. Distinct from the
* action-stream traffic the rest of the netcode uses, which is UDP-style
* best-effort with per-frame back-fill compensating for losses.
*
* Wire layout (after the {@link Channel}'s 8-byte header and the
* `packet_type` byte that {@link NetworkPeer}'s dispatcher consumes):
*
* uintVar logical_seq sender-assigned, monotonically increasing
* bytes command_payload
*
* Reliability mechanism: each outgoing command goes out as one
* Channel.send call. The returned channel-level seq is tracked in
* `__unacked`. When the Channel later fires:
* - `onPacketAcked(seq)`: drop the entry — the command was delivered
* (the receiver de-duplicated by logical_seq, so a "duplicate" from
* a previous retransmit is harmless).
* - `onPacketLost(seq)`: the Channel's 33-packet ack window aged out
* this seq without seeing it acked. Re-send the same logical
* command on a fresh channel seq.
*
* Receiver side: a sliding-window de-dupe of recent `logical_seq` values.
* Anything seen within the last `max_received_history` is silently
* dropped; first-time arrivals fire `onCommand` with the payload bytes.
*
* Ordering: NOT guaranteed. Commands may arrive in any order. If you
* need in-order delivery (level transitions before subsequent chat),
* key payloads with a counter and reorder in the application handler.
*
* Lifecycle constraints:
* - `max_unacked` caps outstanding commands. Send throws when full
* (signals real backpressure — receiver gone or path is broken).
* - `max_received_history` caps the de-dupe set. A retransmit older
* than this window will be re-delivered as a duplicate to the
* application; not a problem in practice because the sender's
* `max_unacked` keeps the inflight set bounded too.
*
* Caveats:
* - Loss detection relies on the Channel's seq window, which advances
* when subsequent packets are sent. Under sustained no-traffic
* conditions a single lost reliable command may stay unacked
* indefinitely — by convention the action-stream's per-tick traffic
* keeps the window moving even when the application is otherwise
* idle. Real production code may want a timer-based retransmit
* fallback layered on top of this.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class ReliableCommandPipeline {
#next_send_seq;
#unacked;
#received_seqs;
#received_order;
#send_scratch;
#bound_acked;
#bound_lost;
/**
* @param {{
* channel: import("./Channel.js").Channel,
* packet_type: number,
* max_unacked?: number,
* max_received_history?: number,
* max_retries?: number,
* }} options
*/
constructor({
channel,
packet_type,
max_unacked = 64,
max_received_history = 64,
max_retries = 16,
}) {
assert.ok(channel && typeof channel.send === 'function', 'channel must be a Channel');
assert.isNonNegativeInteger(packet_type, 'packet_type');
assert.ok(packet_type <= 0xFF, 'packet_type must fit in a uint8');
assert.isPositiveInteger(max_unacked, 'max_unacked');
assert.isPositiveInteger(max_received_history, 'max_received_history');
assert.isPositiveInteger(max_retries, 'max_retries');
/** @type {import("./Channel.js").Channel} */
this.channel = channel;
/** @type {number} @readonly */
this.packet_type = packet_type;
/** @type {number} @readonly */
this.max_unacked = max_unacked;
/** @type {number} @readonly */
this.max_received_history = max_received_history;
/**
* Maximum retransmissions per logical command before it is dropped
* and {@link onCommandAbandoned} fires. Bounded so a permanently-
* black-holed peer can't pin retransmit traffic on the channel
* forever. The default (16) tolerates roughly half a second of
* sustained loss at typical channel cadences before giving up.
* @type {number} @readonly
*/
this.max_retries = max_retries;
/**
* Monotonic counter for outgoing logical seqs. Receiver dedups on
* this value; sender retransmits use the SAME logical_seq with a
* new channel_seq.
* @type {number}
* @private
*/
this.#next_send_seq = 0;
/**
* Outstanding commands keyed by their CURRENT channel_seq. On
* retransmit, the entry is moved to the new channel_seq.
* `retries` counts how many times this logical command has been
* resent; the entry is dropped when it exceeds `max_retries`.
* @type {Map<number, {logical_seq: number, payload: Uint8Array, retries: number}>}
* @private
*/
this.#unacked = new Map();
/**
* Logical seqs received recently. Sliding window for dedup.
* @type {Set<number>}
* @private
*/
this.#received_seqs = new Set();
/**
* FIFO order of received logical_seqs, for pruning when
* `__received_seqs` exceeds `max_received_history`.
* @type {number[]}
* @private
*/
this.#received_order = [];
/**
* Scratch buffer for assembling outgoing packets.
* @type {BinaryBuffer}
* @private
*/
this.#send_scratch = new BinaryBuffer();
this.#send_scratch.setCapacity(1024);
/**
* Fired when a brand-new logical command arrives (not a duplicate).
* Args: `(buf, payload_offset, payload_length)`. The buffer is
* shared scratch; consumers must copy bytes if they need them to
* outlive the handler.
* @type {Signal}
*/
this.onCommand = new Signal();
/**
* Fired when a sent command has been retransmitted `max_retries`
* times without an ack and is being given up on. Args:
* `(logical_seq)`. Subscribers typically surface this as a "command
* failed to deliver" event to the application, or treat it as a
* hard signal to tear down the session.
* @type {Signal}
*/
this.onCommandAbandoned = new Signal();
// Channel reliability notifications.
this.#bound_acked = (seq) => this.#on_packet_acked(seq);
this.#bound_lost = (seq) => this.#on_packet_lost(seq);
channel.onPacketAcked.add(this.#bound_acked);
channel.onPacketLost.add(this.#bound_lost);
}
/**
* Send a logical command. Returns the assigned `logical_seq` (useful
* for application-level diagnostics; the receiver dedups internally
* regardless).
*
* @param {Uint8Array} payload
* @param {number} length
* @returns {number}
*/
send(payload, length) {
assert.isNonNegativeInteger(length, 'length');
if (this.#unacked.size >= this.max_unacked) {
throw new Error(
`ReliableCommandPipeline.send: ${this.#unacked.size} commands unacked ` +
`(max ${this.max_unacked}); receiver likely disconnected or path saturated`
);
}
const logical_seq = this.#next_send_seq;
this.#next_send_seq++;
// Defensive copy: the caller's buffer may be reused after this call.
const payload_copy = new Uint8Array(length);
payload_copy.set(payload.subarray(0, length));
const channel_seq = this.#transmit(logical_seq, payload_copy);
this.#unacked.set(channel_seq, { logical_seq, payload: payload_copy, retries: 0 });
return logical_seq;
}
/**
* Called by the orchestrator (typically {@link NetworkPeer}'s
* dispatcher) when a packet of this pipeline's type arrives. `buf`
* is positioned just past the packet-type byte.
*
* @param {BinaryBuffer} buf
* @param {number} total_length full transport-level payload length,
* including the consumed packet-type byte
*/
handle_inbound(buf, total_length) {
const logical_seq = buf.readUintVar();
if (this.#received_seqs.has(logical_seq)) {
// Duplicate from a retransmit; ignore.
return;
}
this.#received_seqs.add(logical_seq);
this.#received_order.push(logical_seq);
// Prune. Oldest-first.
while (this.#received_order.length > this.max_received_history) {
const oldest = this.#received_order.shift();
this.#received_seqs.delete(oldest);
}
const payload_offset = buf.position;
const payload_length = total_length - payload_offset;
this.onCommand.send3(buf, payload_offset, payload_length);
}
/**
* Number of commands sent that haven't yet been acked. Useful for
* backpressure / progress UI.
* @returns {number}
*/
unacked_count() {
return this.#unacked.size;
}
/**
* Drop subscriptions and clear state. Required before discarding a
* pipeline whose channel will outlive it.
*/
dispose() {
this.channel.onPacketAcked.remove(this.#bound_acked);
this.channel.onPacketLost.remove(this.#bound_lost);
this.#unacked.clear();
this.#received_seqs.clear();
this.#received_order.length = 0;
}
/** @private */
#transmit(logical_seq, payload) {
const buf = this.#send_scratch;
// Worst-case capacity: type byte + 10 bytes for varint + payload.
const needed = 1 + 10 + payload.length;
if (buf.raw_bytes.length < needed) buf.setCapacity(needed);
buf.position = 0;
buf.writeUint8(this.packet_type);
buf.writeUintVar(logical_seq);
buf.writeBytes(payload, 0, payload.length);
return this.channel.send(buf.raw_bytes, buf.position);
}
/** @private */
#on_packet_acked(channel_seq) {
this.#unacked.delete(channel_seq);
}
/** @private */
#on_packet_lost(channel_seq) {
const entry = this.#unacked.get(channel_seq);
if (entry === undefined) return;
this.#unacked.delete(channel_seq);
if (entry.retries >= this.max_retries) {
// Permanently black-holed peer or fundamentally broken path —
// surface the failure to the application and stop retrying.
this.onCommandAbandoned.send1(entry.logical_seq);
return;
}
// Retransmit on a fresh channel_seq. Same logical_seq, same payload.
entry.retries++;
const new_channel_seq = this.#transmit(entry.logical_seq, entry.payload);
this.#unacked.set(new_channel_seq, entry);
}
}