@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
153 lines (130 loc) • 4.42 kB
JavaScript
import { assert } from "../../../core/assert.js";
/**
* Estimates the clock offset between this peer and a remote peer using the
* NTP four-timestamp protocol.
*
* Each round trip provides four timestamps:
* t0: local time when the request was sent
* t1: remote time when the request was received
* t2: remote time when the response was sent
* t3: local time when the response was received
*
* From these:
* `RTT = (t3 - t0) - (t2 - t1)` — excludes remote processing time
* `offset = ((t1 - t0) + (t2 - t3)) / 2` — remote_time = local_time + offset
*
* Time units are application-defined; the module just does arithmetic. Callers
* typically pass milliseconds.
*
* Reports the offset from the sample with lowest RTT (the standard SNTP estimator — error on offset is bounded by ±RTT/2, so the lowest-RTT sample is the tightest estimate)
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class TimeSync {
#rtt_samples;
#offset_samples;
#sample_cursor;
#best_rtt;
#best_offset;
/**
* @param {{ window_size?: number }} [options]
*/
constructor({ window_size = 8 } = {}) {
assert.isPositiveInteger(window_size, 'window_size');
/**
* @readonly
* @type {number}
*/
this.window_size = window_size;
// initializing samples to Infinity prevents them from being picked for "best" and saves us having to keep an extra "length/size" count.
/**
* @type {Float64Array}
* @private
*/
this.#rtt_samples = new Float64Array(window_size).fill(Infinity);
/**
* @type {Float64Array}
* @private
*/
this.#offset_samples = new Float64Array(window_size).fill(Infinity);
/**
*
* @type {number}
* @private
*/
this.#sample_cursor = 0;
/**
* @type {number}
* @private
*/
this.#best_rtt = 0;
/**
* @type {number}
* @private
*/
this.#best_offset = 0;
}
/**
* Record a complete four-timestamp sample.
*
* @param {number} t0 local time at send
* @param {number} t1 remote time at receive
* @param {number} t2 remote time at send
* @param {number} t3 local time at receive
*/
record(t0, t1, t2, t3) {
assert.isNumber(t0, 't0');
assert.isNumber(t1, 't1');
assert.isNumber(t2, 't2');
assert.isNumber(t3, 't3');
const rtt = (t3 - t0) - (t2 - t1);
const offset = ((t1 - t0) + (t2 - t3)) / 2;
const sample_index = this.#sample_cursor;
// advance the cursor to the next sample
this.#sample_cursor = (this.#sample_cursor + 1) % this.window_size;
this.#rtt_samples[sample_index] = rtt;
this.#offset_samples[sample_index] = offset;
// as per SNTP, pick lowest RTT sample
// see https://www.rfc-editor.org/rfc/rfc5905.html#section-10
let best_rtt = this.#rtt_samples[0];
let best_offset = this.#offset_samples[0];
const sample_count = this.window_size;
for (let i = 1; i < sample_count; i++) {
if (this.#rtt_samples[i] < best_rtt) {
best_rtt = this.#rtt_samples[i];
best_offset = this.#offset_samples[i];
}
}
this.#best_rtt = best_rtt;
this.#best_offset = best_offset;
}
/**
* RTT of the sample whose offset is currently being reported — i.e. the
* lowest RTT in the current window. This is the *best-case* round-trip,
* not a typical one. Don't use it to size timeouts, jitter buffers, or
* render delay; for that, measure observed jitter directly
* (see `AdaptiveRenderDelay`).
*
* @returns {number}
*/
best_rtt() {
return this.#best_rtt;
}
/**
* Estimated `remote_time - local_time`. Add to a local timestamp to get the
* estimated remote timestamp.
* @returns {number}
*/
offset() {
return this.#best_offset;
}
/**
* Convert a local timestamp to the corresponding estimated remote timestamp.
* @param {number} local_time
* @returns {number}
*/
estimate_remote(local_time) {
return local_time + this.#best_offset;
}
}