@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
128 lines • 5.97 kB
TypeScript
/**
* Estimates the snapshot-interpolation render delay (in frames) that the
* receiver should hold the playhead behind the latest received frame, based
* on observed inter-arrival jitter.
*
* Why adaptive: a fixed delay is a tradeoff between smoothness (large delay
* absorbs more jitter) and responsiveness (small delay = less perceived lag).
* If we pick a fixed delay sized for the worst case, we pay that cost even
* when the network is calm. If we size it for the typical case, we stutter
* during jitter spikes. Source / Counter-Strike use an adaptive scheme that
* grows quickly when jitter is observed and decays slowly when the link is
* stable. This module is a small reusable implementation of that idea.
*
* Algorithm (per-frame lateness, not inter-arrival time):
* 1. Each `record_arrival(now_ms, frame_number)` computes
* `lateness = now_ms - frame_number * tick_period_ms`. The absolute value
* is meaningless (depends on time origin and one-way latency), but the
* *spread* across recent frames captures the jitter.
* 2. The rolling window holds the last N lateness samples; the recommended
* target delay (ms) = `(max(window) - min(window)) * safety_multiplier`.
* 3. Output ramps UP to a higher target instantly (so a jitter spike is
* absorbed at the next sample), and DOWN at most `decay_per_sample_ms`
* ms per arrival (so we don't flap when one calm packet follows a spike).
* 4. The output is clamped to `[min_delay_frames, max_delay_frames]` and
* converted to an integer frame count.
*
* Why not inter-arrival time: in a tick-driven receiver (e.g. JS engine
* polling a transport queue at 60 Hz), arrivals are quantized to tick
* boundaries. Bursts of frames in one packet share the same arrival time;
* the inter-arrival interval between consecutive applied frames is then
* either 0 (within a burst — useless) or ~tick_period (next tick — also
* useless). Massive network jitter shows up not as huge inter-arrival
* intervals but as `frame_number` deltas wildly out of step with wall time.
* Tracking lateness directly captures this.
*
* **`tick_period_ms` MUST match the sender's actual tick period.** Mismatch
* is silent and catastrophic: if the sender ticks at 240 Hz but you pass
* 1000/60 = 16.67 ms, the lateness math drifts ~12.5 ms per frame, the
* spread saturates within ~30 frames, and the estimator pins to
* `max_delay_frames` regardless of actual jitter. If your simulation runs
* on `requestAnimationFrame`, do NOT use the display refresh as your tick
* period — drive the simulation at a fixed timestep (accumulator pattern)
* and pass that fixed period here.
*
* No allocation per call, no I/O, no engine knowledge.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class AdaptiveRenderDelay {
/**
* @param {{
* tick_period_ms: number,
* min_delay_frames?: number,
* max_delay_frames?: number,
* history_size?: number,
* safety_multiplier?: number,
* decay_per_sample_ms?: number,
* initial_delay_frames?: number,
* }} options
*
* `safety_multiplier`: how much headroom to add above the observed jitter
* spread. 2.0 (the default) follows the rule of thumb "render delay
* should cover 2× the observed jitter spread" and reproduces visibly
* smooth motion in practice.
*
* `decay_per_sample_ms`: how aggressively the recommendation drops once
* conditions improve. Smaller = stickier (the delay stays high even after
* a calm period), larger = snappier (the delay drops faster, possibly
* stuttering if jitter recurs). Default 1 ms / sample is a calm decay
* that takes ~1 s to drop by 60 ms at 60 Hz arrivals.
*/
constructor({ tick_period_ms, min_delay_frames, max_delay_frames, history_size, safety_multiplier, decay_per_sample_ms, initial_delay_frames, }: {
tick_period_ms: number;
min_delay_frames?: number;
max_delay_frames?: number;
history_size?: number;
safety_multiplier?: number;
decay_per_sample_ms?: number;
initial_delay_frames?: number;
});
/** @readonly @type {number} */
readonly tick_period_ms: number;
/** @readonly @type {number} */
readonly min_delay_frames: number;
/** @readonly @type {number} */
readonly max_delay_frames: number;
/** @readonly @type {number} */
readonly safety_multiplier: number;
/** @readonly @type {number} */
readonly decay_per_sample_ms: number;
/**
* Record an arrival. Call this when a new frame from the sender has been
* applied locally.
*
* @param {number} now_ms wall-clock timestamp (e.g. `performance.now()`)
* @param {number} frame_number sender's monotonically-advancing frame index
*/
record_arrival(now_ms: number, frame_number: number): void;
/**
* Recommended render delay in frames, integer, clamped to the configured
* `[min_delay_frames, max_delay_frames]` window.
*
* @returns {number}
*/
delay_frames(): number;
/**
* Recommended render delay in milliseconds (the unsmoothed-by-frame-rounding
* value, useful for logging / HUD readouts).
* @returns {number}
*/
delay_ms(): number;
/**
* Most recent inter-arrival WALL TIME interval in ms. Returns -1 if fewer
* than 2 arrivals have been observed. NOTE: this is NOT what the
* estimator uses internally — it's just exposed for diagnostics / HUD.
* @returns {number}
*/
last_interval_ms(): number;
/**
* Drop all observed history. Useful after a reconnect or a level transition.
* Leaves `current_delay_ms` as-is so the displayed delay doesn't snap; it
* will adjust to new conditions over the next few samples.
*/
reset(): void;
#private;
}
//# sourceMappingURL=AdaptiveRenderDelay.d.ts.map