@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
584 lines (517 loc) • 22.9 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
/** Words per record: [network_id, type_id, byte_offset, byte_length]. 4 × u32 = 16 B. */
const RECORD_WORDS = 4;
/** 256-bit bloom filter per page → 8 × u32. */
const BLOOM_WORDS = 8;
/**
* Records per page. Sized so that with k=3 hash functions on a 256-bit filter
* the false-positive rate is just under 5% — `(1 - exp(-3·39/256))^3 ≈ 0.048`.
*/
const METADATA_PAGE_SIZE = 39;
/** Total words per fully-occupied page. Underused pages are smaller. */
const MAX_PAGE_WORDS = BLOOM_WORDS + METADATA_PAGE_SIZE * RECORD_WORDS; // 164
/**
* Reusable scratch row used by {@link InterpolationLog#interpolate} to hold
* the (offset, length) pairs from two `locate` calls. Six slots × u32 — the
* two pairs live at indices [4..5] and [6..7]; lower slots are spare for any
* future scratch use.
* @type {Uint32Array}
*/
const scratch_u32 = new Uint32Array(8);
/**
* Insert a u32 key into the 256-bit bloom filter at words `[base..base+7]` of `table`.
* Reference: standard double-hashing trick, k=3 hashes derived from one murmur3 mix.
*
* @param {Uint32Array} table
* @param {number} base word offset of the bloom filter's first word
* @param {number} key u32 to hash
*/
function bloom_insert(table, base, key) {
let h = key | 0;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
const h1 = Math.imul(h, 0xc2b2ae35);
const h2 = h1 ^ (h1 >>> 16);
for (let i = 0; i < 3; i++) {
const bit_index = (h1 + Math.imul(i, h2)) & 255;
const word_index = bit_index >>> 5;
table[base + word_index] |= (1 << bit_index);
}
}
/**
* Bloom-filter membership test. Returns false ⇒ definitely not present;
* true ⇒ likely present (false-positive rate per page ≈ 5%).
*
* @param {Uint32Array} table
* @param {number} base
* @param {number} key
* @returns {boolean}
*/
function bloom_contains(table, base, key) {
let h = key | 0;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
const h1 = Math.imul(h, 0xc2b2ae35);
const h2 = h1 ^ (h1 >>> 16);
for (let i = 0; i < 3; i++) {
const bit_index = (h1 + Math.imul(i, h2)) & 255;
const word_index = bit_index >>> 5;
if ((table[base + word_index] & (1 << bit_index)) === 0) return false;
}
return true;
}
/**
* Per-tick recording of replicated component state for snapshot interpolation.
*
* Two ring buffers — both wrap at tick boundaries only:
*
* - `__buffer` (BinaryBuffer): raw component bytes, written via `begin_record`
* / `end_record`. Variable size per record.
*
* - `__records` (Uint32Array): the metadata table, holding one row per
* `(network_id, type_id, byte_offset, byte_length)` plus a bloom-filter
* header every {@link METADATA_PAGE_SIZE} records. The structure:
*
* page = [bloom_0..7] [rec_0_n, _t, _o, _l] ... [rec_38_n, _t, _o, _l]
*
* A new page header is written at `begin_tick` and then again every 39
* records within a tick. The bloom filter is keyed by `network_id`, so a
* `locate(network_id, type_id)` can skip whole pages with a single 256-bit
* check before falling back to a per-record scan inside the page. Pages
* never straddle tick boundaries.
*
* Per-tick metadata is small: byte range + word range in `__records` + record
* count. The oldest tick is tracked explicitly (`__oldest_tick`) so the squash
* check at each {@link begin_tick} is amortized O(1) — squash the oldest while
* it overlaps the new write region, advance the pointer, repeat.
*
* Pair with {@link BinaryInterpolationAdapter} concrete subclasses (e.g.
* {@link Vector3InterpolationAdapter}) which know each component type's
* encoding and blend math.
*
* Typical receive-side use:
* ```
* onFrameApplied(_, frame_number) {
* log.begin_tick(frame_number);
* const buf = log.begin_record(network_id, type_id);
* adapter.serialize(buf, value);
* log.end_record();
* log.end_tick();
* }
* ```
*
* Typical render-time use:
* ```
* const ok = log.interpolate(out_buffer, network_id, type_id, tick_a, tick_b, t, adapter);
* if (ok) { /* deserialize from out_buffer into the live component }
* ```
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class InterpolationLog {
#buffer;
#buffer_cursor;
#records;
#records_word_capacity;
#records_cursor;
#current_page_start;
#records_in_current_page;
#ticks;
#oldest_tick;
#max_tick_bytes;
#max_tick_words;
#current_tick;
#pending_record_start;
#pending_network_id;
#pending_type_id;
/**
* @param {{ buffer_capacity_bytes?: number, records_capacity?: number }} [options]
* `records_capacity` is in *records* (not bytes); the underlying word
* array is sized large enough to fit that many records plus their pages'
* bloom-filter headers.
*/
constructor({ buffer_capacity_bytes = 65536, records_capacity = 4096 } = {}) {
assert.isNonNegativeInteger(buffer_capacity_bytes, 'buffer_capacity_bytes');
assert.ok(buffer_capacity_bytes >= 256, 'buffer_capacity_bytes must be >= 256');
assert.isNonNegativeInteger(records_capacity, 'records_capacity');
assert.ok(records_capacity >= 1, 'records_capacity must be >= 1');
/**
* Component-bytes ring. Reads (during {@link interpolate}) move the
* buffer's position cursor; we track the next write position
* separately in `__buffer_cursor` so reads don't clobber it.
* @private @type {BinaryBuffer}
*/
this.#buffer = new BinaryBuffer();
this.#buffer.fromArrayBuffer(new ArrayBuffer(buffer_capacity_bytes));
/** @readonly @type {number} */
this.buffer_capacity = buffer_capacity_bytes;
/** @private @type {number} */
this.#buffer_cursor = 0;
/**
* Metadata table — flat Uint32Array of pages back-to-back. Sized for
* the worst case of every page being full; if the workload doesn't
* fill pages, the wasted slots just sit empty.
* @private @type {Uint32Array}
*/
const num_pages = Math.ceil(records_capacity / METADATA_PAGE_SIZE);
const total_words = num_pages * MAX_PAGE_WORDS;
this.#records = new Uint32Array(total_words);
/** @readonly @type {number} */
this.records_capacity = records_capacity;
/** Total word capacity of the metadata table (used for wrap/overlap math). @readonly @type {number} */
this.#records_word_capacity = total_words;
/** @private @type {number} Next word position to write. */
this.#records_cursor = 0;
/**
* Current page state. While a tick is open, `__current_page_start` is
* the word offset of the bloom-filter header for the page being
* filled, and `__records_in_current_page` counts how many of its
* 39 record slots are already populated.
* @private @type {number}
*/
this.#current_page_start = -1;
/** @private @type {number} */
this.#records_in_current_page = 0;
/**
* Per-tick metadata, keyed by tick number. Map preserves insertion
* order, which equals chronological order; the first key is therefore
* always the oldest tick, mirrored by `__oldest_tick`.
*
* Entry shape: `{ byte_start, byte_end, record_start_word,
* record_word_count, record_count }`.
*
* @private @type {Map<number, {byte_start: number, byte_end: number, record_start_word: number, record_word_count: number, record_count: number}>}
*/
this.#ticks = new Map();
/**
* Tick number of the oldest live tick, or -1 if empty.
* @private @type {number}
*/
this.#oldest_tick = -1;
/**
* Largest single-tick byte / word footprint observed. Used as the
* estimated-tick-size at {@link begin_tick} to decide whether to wrap
* pre-emptively.
* @private @type {number}
*/
this.#max_tick_bytes = 0;
/** @private @type {number} */
this.#max_tick_words = 0;
/** @private @type {number} */
this.#current_tick = -1;
/**
* Open-record state. -1 when no record is currently being written;
* otherwise the byte offset where the in-progress record's payload
* began (so {@link end_record} can compute the length).
* @private @type {number}
*/
this.#pending_record_start = -1;
/** @private @type {number} */
this.#pending_network_id = 0;
/** @private @type {number} */
this.#pending_type_id = 0;
}
/**
* Open a new tick for recording. May wrap either ring at this point — but
* never mid-tick, so a single tick's data is always contiguous in both
* the byte buffer and the metadata table. After wrapping, any old tick
* whose data overlaps the new write region is squashed (in oldest-first
* order) and `__oldest_tick` advances.
*
* @param {number} tick non-negative integer
*/
begin_tick(tick) {
assert.isNonNegativeInteger(tick, 'tick');
if (this.#current_tick !== -1) {
throw new Error(`InterpolationLog.begin_tick: previous tick ${this.#current_tick} still open; call end_tick first`);
}
// Wrap the byte buffer if remaining < typical tick size.
if (this.buffer_capacity - this.#buffer_cursor < this.#max_tick_bytes) {
this.#buffer_cursor = 0;
}
// Wrap the metadata table similarly.
if (this.#records_word_capacity - this.#records_cursor < this.#max_tick_words) {
this.#records_cursor = 0;
}
// Estimate the new tick's byte and word ranges (worst-case = max-observed).
const new_byte_start = this.#buffer_cursor;
const new_byte_end_est = new_byte_start + this.#max_tick_bytes;
const new_word_start = this.#records_cursor;
const new_word_end_est = new_word_start + this.#max_tick_words;
// Squash oldest ticks while they overlap. O(1) amortized.
while (this.#oldest_tick !== -1) {
const oldest_meta = this.#ticks.get(this.#oldest_tick);
const byte_overlap =
oldest_meta.byte_end > new_byte_start &&
oldest_meta.byte_start < new_byte_end_est;
const word_overlap =
(oldest_meta.record_start_word + oldest_meta.record_word_count) > new_word_start &&
oldest_meta.record_start_word < new_word_end_est;
if (!byte_overlap && !word_overlap) break;
this.#ticks.delete(this.#oldest_tick);
const next = this.#ticks.keys().next();
this.#oldest_tick = next.done ? -1 : next.value;
}
// Open this tick's first page: write a fresh (zero) bloom filter header.
this.#current_page_start = this.#records_cursor;
const records = this.#records;
records[this.#current_page_start + 0] = 0;
records[this.#current_page_start + 1] = 0;
records[this.#current_page_start + 2] = 0;
records[this.#current_page_start + 3] = 0;
records[this.#current_page_start + 4] = 0;
records[this.#current_page_start + 5] = 0;
records[this.#current_page_start + 6] = 0;
records[this.#current_page_start + 7] = 0;
this.#records_cursor += BLOOM_WORDS;
this.#records_in_current_page = 0;
const meta = {
byte_start: new_byte_start,
byte_end: new_byte_start,
record_start_word: this.#current_page_start,
record_word_count: BLOOM_WORDS,
record_count: 0,
};
this.#ticks.set(tick, meta);
if (this.#oldest_tick === -1) {
this.#oldest_tick = tick;
}
this.#current_tick = tick;
}
/**
* Begin recording one component. Returns the underlying byte buffer
* positioned at the next write slot — the caller writes the component
* payload directly (e.g. via a `BinaryClassSerializationAdapter.serialize`
* call), then calls {@link end_record} to finalize the (offset, length).
*
* Splitting begin/end (vs. taking a write callback) avoids the per-record
* closure allocation and lets the caller pass the buffer straight to an
* adapter without wrapping.
*
* @param {number} network_id
* @param {number} type_id component type id (small integer; matches the registry)
* @returns {BinaryBuffer} the log's byte buffer, positioned for writing the payload
*/
begin_record(network_id, type_id) {
assert.isNonNegativeInteger(network_id, 'network_id');
assert.isNonNegativeInteger(type_id, 'type_id');
if (this.#current_tick === -1) {
throw new Error('InterpolationLog.begin_record: no tick is open; call begin_tick first');
}
if (this.#pending_record_start !== -1) {
throw new Error('InterpolationLog.begin_record: previous record still open; call end_record first');
}
// If the current page is full, open a new one before this record's
// metadata gets written.
if (this.#records_in_current_page >= METADATA_PAGE_SIZE) {
const records = this.#records;
this.#current_page_start = this.#records_cursor;
records[this.#current_page_start + 0] = 0;
records[this.#current_page_start + 1] = 0;
records[this.#current_page_start + 2] = 0;
records[this.#current_page_start + 3] = 0;
records[this.#current_page_start + 4] = 0;
records[this.#current_page_start + 5] = 0;
records[this.#current_page_start + 6] = 0;
records[this.#current_page_start + 7] = 0;
this.#records_cursor += BLOOM_WORDS;
this.#records_in_current_page = 0;
}
const buf = this.#buffer;
buf.position = this.#buffer_cursor;
this.#pending_record_start = buf.position;
this.#pending_network_id = network_id;
this.#pending_type_id = type_id;
return buf;
}
/**
* Finalize the in-progress record opened by {@link begin_record}. Reads
* the buffer's current position to compute the payload length, inserts
* the network_id into the current page's bloom filter, writes the row to
* the metadata table, and advances the cursors.
*/
end_record() {
if (this.#pending_record_start === -1) {
throw new Error('InterpolationLog.end_record: no record is open; call begin_record first');
}
const buf = this.#buffer;
const start = this.#pending_record_start;
const end = buf.position;
const length = end - start;
this.#buffer_cursor = end;
const records = this.#records;
const nid = this.#pending_network_id;
// Update the current page's bloom filter for fast skip-by-network_id.
bloom_insert(records, this.#current_page_start, nid);
// Write the record at the cursor.
const at = this.#records_cursor;
records[at + 0] = nid;
records[at + 1] = this.#pending_type_id;
records[at + 2] = start;
records[at + 3] = length;
this.#records_cursor += RECORD_WORDS;
this.#records_in_current_page++;
const meta = this.#ticks.get(this.#current_tick);
meta.byte_end = end;
meta.record_count++;
const grown_word_count = this.#records_cursor - meta.record_start_word;
meta.record_word_count = grown_word_count;
// Overrun guard: the begin_tick wrap heuristic sizes off the largest
// *previously observed* tick. A tick whose footprint exceeds any prior
// one can advance the cursors into the live range of an older tick.
// Only check (and squash) when this tick is actively exceeding the
// envelope — steady-state ticks skip both branches.
const grown_byte_count = end - meta.byte_start;
if (grown_byte_count > this.#max_tick_bytes || grown_word_count > this.#max_tick_words) {
while (this.#oldest_tick !== -1 && this.#oldest_tick !== this.#current_tick) {
const oldest_meta = this.#ticks.get(this.#oldest_tick);
const byte_overlap =
end > oldest_meta.byte_start && meta.byte_start < oldest_meta.byte_end;
const word_overlap =
this.#records_cursor > oldest_meta.record_start_word &&
meta.record_start_word < oldest_meta.record_start_word + oldest_meta.record_word_count;
if (!byte_overlap && !word_overlap) break;
this.#ticks.delete(this.#oldest_tick);
const next = this.#ticks.keys().next();
this.#oldest_tick = next.done ? -1 : next.value;
}
}
this.#pending_record_start = -1;
}
/**
* Close the currently-open tick. Updates the max-observed-tick sizes so
* the next {@link begin_tick} can size its wrap heuristic correctly.
*
* Throws if a record is still open — silently dropping the in-progress
* record's metadata (offset/length) would leak buffer space and produce
* stale bytes that no one can locate.
*/
end_tick() {
if (this.#current_tick === -1) {
throw new Error('InterpolationLog.end_tick: no tick is open; call begin_tick first');
}
if (this.#pending_record_start !== -1) {
throw new Error(`InterpolationLog.end_tick: record (network_id=${this.#pending_network_id}, type_id=${this.#pending_type_id}) still open; call end_record first`);
}
const meta = this.#ticks.get(this.#current_tick);
const tick_bytes = meta.byte_end - meta.byte_start;
if (tick_bytes > this.#max_tick_bytes) this.#max_tick_bytes = tick_bytes;
if (meta.record_word_count > this.#max_tick_words) this.#max_tick_words = meta.record_word_count;
this.#current_tick = -1;
}
/**
* @param {number} tick
* @returns {boolean} true if the tick has live (non-squashed) data
*/
has_tick(tick) {
return this.#ticks.has(tick);
}
/**
* Find a component's stored slice within a tick. Walks the tick's pages,
* bloom-filter-skipping pages that don't contain `network_id`, and
* scanning records inside any page where the bloom hits.
*
* On success, writes `out[out_offset+0] = byte_offset` and
* `out[out_offset+1] = byte_length`.
*
* @param {number[]|Uint32Array} out
* @param {number} out_offset
* @param {number} tick
* @param {number} network_id
* @param {number} type_id
* @returns {boolean} false if the tick is missing/squashed or doesn't carry this component
*/
locate(out, out_offset, tick, network_id, type_id) {
const meta = this.#ticks.get(tick);
if (meta === undefined) return false;
const records = this.#records;
let page_start = meta.record_start_word;
let records_remaining = meta.record_count;
while (records_remaining > 0) {
const records_in_this_page = Math.min(records_remaining, METADATA_PAGE_SIZE);
// Skip the whole page if the bloom filter says network_id isn't in it.
if (bloom_contains(records, page_start, network_id)) {
const records_base = page_start + BLOOM_WORDS;
for (let i = 0; i < records_in_this_page; i++) {
const r = records_base + i * RECORD_WORDS;
if (records[r + 0] === network_id && records[r + 1] === type_id) {
out[out_offset + 0] = records[r + 2];
out[out_offset + 1] = records[r + 3];
return true;
}
}
}
page_start += BLOOM_WORDS + records_in_this_page * RECORD_WORDS;
records_remaining -= records_in_this_page;
}
return false;
}
/**
* Interpolate one component between two ticks via `adapter` and write the
* result to `out_buffer` at its current position.
*
* Outcome by available data:
* - Both ticks carry the component → adapter blends them at `t`.
* - Only one carries it → adapter is called with the same offset twice
* (snap to the surviving snapshot; `t` is irrelevant).
* - Neither carries it → returns false; `out_buffer` is unchanged.
*
* @param {BinaryBuffer} out_buffer
* @param {number} network_id
* @param {number} type_id
* @param {number} tick_a
* @param {number} tick_b
* @param {number} t
* @param {BinaryInterpolationAdapter} adapter
* @returns {boolean} true if a payload was written; false if neither tick has the component
*/
interpolate(out_buffer, network_id, type_id, tick_a, tick_b, t, adapter) {
const a = this.locate(scratch_u32, 4, tick_a, network_id, type_id);
const b = this.locate(scratch_u32, 6, tick_b, network_id, type_id);
if (!(a || b)) return false;
const offset_a = scratch_u32[4];
const offset_b = scratch_u32[6];
const first = a ? offset_a : offset_b;
const second = b ? offset_b : offset_a;
// Adapter is free to mutate __buffer.position (it sets it to specific
// offsets to read). Save & restore so subsequent reads/writes aren't
// surprised by a moved cursor.
const buffer = this.#buffer;
const saved = buffer.position;
adapter.interpolate(out_buffer, buffer, first, second, t);
buffer.position = saved;
return true;
}
/**
* Number of ticks currently held. Squashed ticks are excluded.
* @returns {number}
*/
size() {
return this.#ticks.size;
}
/**
* Tick number of the oldest live tick, or -1 if empty.
* @returns {number}
*/
oldest_tick() {
return this.#oldest_tick;
}
/**
* Drop all recorded ticks. Resets both ring cursors to 0. Useful on
* reconnect or level transition.
*/
clear() {
this.#ticks.clear();
this.#buffer_cursor = 0;
this.#records_cursor = 0;
this.#oldest_tick = -1;
this.#current_tick = -1;
this.#pending_record_start = -1;
this.#current_page_start = -1;
this.#records_in_current_page = 0;
}
}