@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
273 lines (239 loc) • 9.74 kB
JavaScript
import { assert } from "../../../core/assert.js";
import Signal from "../../../core/events/signal/Signal.js";
import {
ack_bitfield_build,
ack_bitfield_for_each,
} from "../core/sequence/ack_bitfield.js";
import { SEQ16_HALF_RANGE, seq16_advance, seq16_distance, seq16_greater_than } from "../core/sequence/seq16.js";
/**
* Reliability-notification layer over a {@link Transport}.
*
* Adds an 8-byte header to every packet:
* ```
* uint16: outgoing_seq
* uint16: ack_latest (most recent seq received from peer, or 0 if none)
* uint32: ack_bitfield (the previous 32 seqs: bit i set => latest-1-i was received)
* ```
*
* The header gives positive acknowledgement of up to 33 packets per inbound
* packet — robust enough that a single dropped ack-bearing packet typically
* doesn't matter (a later one carries the same info).
*
* Channel does NOT retransmit. It surfaces:
* - `onPacketAcked(seq)` when a peer's ack indicates an outgoing packet arrived
* - `onPacketLost(seq)` when an outgoing packet falls outside the 33-seq ack
* window without ever being acked (peer has acked 32 newer packets, so this
* one is presumed lost)
* - `onPayload(bytes, length)` for the payload portion of received packets,
* with the header stripped
*
* Reliability-by-retransmission lives in a higher-level pipeline (separate file,
* yet to build). Channel just provides the notification primitive.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
const HEADER_SIZE = 8;
export class Channel {
#next_outgoing_seq;
#unacked_outgoing;
#latest_received_seq;
#received_seqs;
#send_buffer;
#send_view;
#lost_scratch;
/**
* @param {{
* transport: { send(bytes: Uint8Array, length: number): void, onReceive: Signal },
* }} options
*/
constructor({ transport }) {
assert.ok(transport && typeof transport.send === 'function', 'transport must implement send()');
assert.ok(transport.onReceive, 'transport must expose onReceive signal');
/**
* @type {{ send(bytes: Uint8Array, length: number): void, onReceive: Signal }}
*/
this.transport = transport;
this.transport.onReceive.add(this.#handle_inbound, this);
/** @type {number} @private */
this.#next_outgoing_seq = 0;
/**
* Outgoing seqs waiting on ack from the peer.
* @type {Set<number>}
* @private
*/
this.#unacked_outgoing = new Set();
/**
* Most recent seq we've received from the peer; -1 if none yet.
* @type {number}
* @private
*/
this.#latest_received_seq = -1;
/**
* Seqs we have received. Used to build the ack bitfield piggyback.
* Pruned when entries fall outside the 64-seq window.
* @type {Set<number>}
* @private
*/
this.#received_seqs = new Set();
/**
* Pre-allocated send buffer. Resized only if a payload exceeds capacity.
* @type {Uint8Array}
* @private
*/
this.#send_buffer = new Uint8Array(1500);
/** @type {DataView} @private */
this.#send_view = new DataView(this.#send_buffer.buffer);
/**
* Scratch list for collecting "to be marked lost" outgoing seqs during
* an inbound handler (avoids modifying the Set while iterating).
* @type {number[]}
* @private
*/
this.#lost_scratch = [];
/**
* Fired when an outgoing seq has been positively acknowledged by the peer.
* @type {Signal}
*/
this.onPacketAcked = new Signal();
/**
* Fired when an outgoing seq has aged out of the ack window without being acked.
* @type {Signal}
*/
this.onPacketLost = new Signal();
/**
* Fired when an inbound packet's payload is ready (header already stripped).
* Handler args: (payload: Uint8Array starting at offset 0, length: number).
* The Uint8Array is owned by the Channel and may be reused after the handler returns.
* @type {Signal}
*/
this.onPayload = new Signal();
}
/**
* Wrap and send a payload. Returns the seq number assigned to it.
*
* @param {Uint8Array} payload
* @param {number} length payload length in bytes
* @returns {number} seq
*/
send(payload, length) {
assert.isNonNegativeInteger(length, 'length');
const total_size = HEADER_SIZE + length;
if (total_size > this.#send_buffer.length) {
this.#send_buffer = new Uint8Array(Math.max(total_size, this.#send_buffer.length * 2));
this.#send_view = new DataView(this.#send_buffer.buffer);
}
const seq = this.#next_outgoing_seq;
this.#next_outgoing_seq = seq16_advance(seq);
const ack_latest = this.#latest_received_seq < 0 ? 0 : this.#latest_received_seq;
const ack_bitfield = this.#latest_received_seq < 0
? 0
: ack_bitfield_build(this.#latest_received_seq, s => this.#received_seqs.has(s));
this.#send_view.setUint16(0, seq, true);
this.#send_view.setUint16(2, ack_latest, true);
this.#send_view.setUint32(4, ack_bitfield, true);
if (length > 0) {
this.#send_buffer.set(payload.subarray(0, length), HEADER_SIZE);
}
this.transport.send(this.#send_buffer, total_size);
this.#unacked_outgoing.add(seq);
return seq;
}
/**
* Number of outgoing packets sent but not yet acked or marked lost.
* @returns {number}
*/
unacked_count() {
return this.#unacked_outgoing.size;
}
/**
* Most recent seq received from the peer, or -1 if none yet.
* @returns {number}
*/
latest_received_seq() {
return this.#latest_received_seq;
}
/**
* Test-only: seed the next outgoing seq number. Used to fast-forward
* the seq counter so the seq16 wraparound can be exercised in
* unit tests without sending 65k packets. Do not call from production.
* @param {number} seq
*/
seed_outgoing_seq_for_testing(seq) {
this.#next_outgoing_seq = seq;
}
/**
* @param {Uint8Array} bytes
* @param {number} length
* @private
*/
#handle_inbound(bytes, length) {
if (length < HEADER_SIZE) {
// Malformed: too short to contain a header. Drop silently.
return;
}
const view = new DataView(bytes.buffer, bytes.byteOffset, length);
const seq = view.getUint16(0, true);
const ack_latest = view.getUint16(2, true);
const ack_bitfield = view.getUint32(4, true);
// Record receipt of this seq.
if (this.#latest_received_seq < 0 || seq16_greater_than(seq, this.#latest_received_seq)) {
this.#latest_received_seq = seq;
}
this.#received_seqs.add(seq);
this.#prune_received_seqs();
// Mark each acknowledged outgoing seq.
ack_bitfield_for_each(ack_latest, ack_bitfield, acked_seq => {
if (this.#unacked_outgoing.delete(acked_seq)) {
this.onPacketAcked.send1(acked_seq);
}
});
// Anything older than (ack_latest - 32) that is still unacked is presumed lost.
// Sanity: if `d` ever approaches half-range we're flying so many
// unacked packets that distance is about to wrap to negative and the
// loss-detection would silently break. Non-goal to support; assert.
this.#lost_scratch.length = 0;
for (const out_seq of this.#unacked_outgoing) {
const d = seq16_distance(out_seq, ack_latest);
assert.lessThan(d, SEQ16_HALF_RANGE / 2, `Channel: outgoing seq ${out_seq} is ${d} packets behind ack_latest ${ack_latest} — approaching seq16 half-range wraparound`);
if (d > 32) {
this.#lost_scratch.push(out_seq);
}
}
for (let i = 0; i < this.#lost_scratch.length; i++) {
const lost = this.#lost_scratch[i];
this.#unacked_outgoing.delete(lost);
this.onPacketLost.send1(lost);
}
// Strip header and surface the payload.
const payload_length = length - HEADER_SIZE;
const payload = bytes.subarray(HEADER_SIZE, HEADER_SIZE + payload_length);
this.onPayload.send2(payload, payload_length);
}
/**
* Detach this Channel from its transport. The transport.onReceive subscription
* is removed so the Channel stops processing inbound bytes; the Channel's own
* Signals are left in place (callers usually hold no references to them after
* dispose, so they GC with the Channel). Required before discarding a Channel
* whose transport will be reused — otherwise the old Channel keeps consuming
* inbound packets in parallel with whatever replaces it.
*/
dispose() {
this.transport.onReceive.remove(this.#handle_inbound, this);
}
/**
* Drop received-seq entries that are too old to matter for the next ack bitfield.
* Keeps a window slightly larger than the 33-seq bitfield to absorb out-of-order arrival.
* @private
*/
#prune_received_seqs() {
const latest = this.#latest_received_seq;
if (latest < 0) return;
// Window: 64 seqs back. Anything farther can be dropped.
for (const s of this.#received_seqs) {
if (seq16_distance(s, latest) > 64) {
this.#received_seqs.delete(s);
}
}
}
}