@zakkster/lite-history-buffer
v1.0.0
Published
Zero-GC, typed-array history ring buffer for time-travel interpolation.
Maintainers
Readme
@zakkster/lite-history-buffer
⏳ What is lite-history-buffer?
@zakkster/lite-history-buffer is the state-history layer of the Lite ecosystem: a fixed-capacity ring buffer of timestamped snapshots, packed into a single Float32Array, with linear interpolation between the two snapshots that bracket any target time.
You record(time, x, y, ...) once per simulation tick and sample(targetTime, out) once per render frame. Between those two calls, every byte of memory used has already been allocated. Zero heap allocations on the hot path. Zero GC pressure. Zero closures created.
This is the data structure that lives behind:
- 🎮 Render-tick / simulation-tick decoupling — physics at 30 Hz, rendering at 144 Hz, smooth in between.
- 🌐 Snapshot interpolation for netcode — Quake-style smoothing of remote player state arriving over a jittery network.
- ⏪ Rollback / replay — keep the last N frames of state for resimulation, scrubbing, or determinism debugging.
- 👆 High-frequency input history — "where was the cursor 50 ms ago?" without allocating a
{t, x, y}object on every pointer event.
Sub-1 KB min+gzip. Zero dependencies.
🧬 Where it fits
lite-history-buffer is the temporal memory layer of a real-time loop — the bridge between your fixed-rate state producer and your variable-rate state consumer:
flowchart LR
A[Simulation / Physics<br/><sub>fixed Hz</sub>]:::sim --> B[lite-history-buffer<br/><sub>record snapshots</sub>]:::core
N[Network snapshots<br/><sub>jittery</sub>]:::net --> B
I[Input events<br/><sub>variable Hz</sub>]:::input --> B
B --> C[sample at any time t]:::out
C --> D[lite-ease / lite-cubic-bezier<br/><sub>shape the curve</sub>]:::sibling
C --> E[Renderer<br/><sub>variable Hz</sub>]:::render
D --> E
classDef sim fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef net fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef input fill:#fef3c7,stroke:#d97706,color:#78350f
classDef core fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
classDef out fill:#f0fdf4,stroke:#16a34a,color:#14532d
classDef sibling fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
classDef render fill:#f3f4f6,stroke:#6b7280,color:#1f2937Every library in the chain is independent. lite-history-buffer produces plain numbers — no framework lock-in.
🚀 Install
npm install @zakkster/lite-history-buffer🕹️ Quick Start
Render-tick decoupling
Physics ticks at a fixed rate; the renderer needs a position at the current frame time.
import { HistoryBuffer } from '@zakkster/lite-history-buffer';
// Track 2D position over the last 30 simulation snapshots
const positions = new HistoryBuffer({ capacity: 30, stride: 2 });
// Simulation tick (e.g. 30 Hz)
function physicsTick(now) {
updatePhysics();
positions.record(now, entity.x, entity.y);
}
// Render tick (e.g. 144 Hz)
const out = new Float32Array(2); // allocated once, reused forever
function render(now) {
positions.sample(now - 16, out); // sample slightly behind for safety margin
drawAt(out[0], out[1]);
}Netcode snapshot interpolation
Server sends authoritative state every ~50 ms; render at 60 fps.
import { HistoryBuffer } from '@zakkster/lite-history-buffer';
// Buffer ~10 server snapshots of (x, y, rotation)
const remote = new HistoryBuffer({ capacity: 16, stride: 3 });
socket.on('state', ({ t, x, y, rot }) => {
remote.record(t, x, y, rot);
});
const out = new Float32Array(3);
function renderRemote(localNow) {
// Render 100 ms behind the latest snapshot — Quake-style interpolation delay
remote.sample(localNow - 100, out);
drawShip(out[0], out[1], out[2]);
}Rollback netcode primitive
Keep the last N frames of state, rewind on remote-input arrival, resimulate forward.
import { HistoryBuffer } from '@zakkster/lite-history-buffer';
// 8 frames of latency tolerance × packed (health, mana, x, y)
const stateHistory = new HistoryBuffer({ capacity: 8, stride: 4 });
function tick(frame) {
advanceSimulation();
stateHistory.record(frame, player.health, player.mana, player.x, player.y);
}
const snapshot = new Float32Array(4);
function rollbackTo(frame) {
stateHistory.sample(frame, snapshot);
player.health = snapshot[0];
player.mana = snapshot[1];
player.x = snapshot[2];
player.y = snapshot[3];
}🧠 Why this exists
The naïve idiomatic JavaScript solution to "remember the last N states" is something like:
const history = [];
function record(t, x, y) {
history.push({ t, x, y });
if (history.length > 60) history.shift();
}That code does the right thing — and at scale, it falls over. Every push allocates a fresh object. Every shift re-indexes the array. At 60 fps tracking even a hundred entities, that's tens of thousands of objects per second churning through V8's young-generation GC. The result: periodic major-GC pauses that drop frames at exactly the moments your renderer cares about smoothness most.
lite-history-buffer solves this with three moves:
- One contiguous
Float32Array, sized at construction. Frames live as[time, v0, v1, …]packed end-to-end. Cache-friendly. CPU-friendly. SIMD-adjacent. - Ring-buffer overwrite — when full, the next
record()clobbers the physically-oldest slot. No shifting, no copying, no growth. - Discrete numeric arguments for
record(time, v0, v1, v2, v3)and a caller-ownedoutforsample(). Both APIs are designed so V8 never has to box, never has to allocate, and never has a reason to deopt.
The result: one allocation total (the Float32Array), regardless of how long the buffer runs. That's the whole point.
🔥 Algorithm
Sampling a target time runs a backwards walk from the newest frame. For the typical case ("the renderer is just behind simulation"), it terminates in O(1). The worst case — past-clamping deep into the buffer — is O(N), which is still negligible for the buffer sizes this library is designed for (a few dozen frames).
flowchart TD
A[sample t, out]:::start --> B{count == 0?}
B -->|yes| C[return — out untouched]:::trivial
B -->|no| D[read newest frame]:::core
D --> E{t ≥ newestTime?}
E -->|yes| F[write newest → out<br/><sub>future-clamp</sub>]:::out
E -->|no| G[walk backwards<br/><sub>newestIdx → newestIdx-1 → …</sub>]:::walk
G --> H{prev.time ≤ t?}
H -->|yes| I[lerp prev → newest<br/><sub>per stride slot</sub>]:::lerp
H -->|no| J[shift newest := prev<br/>continue walk]:::iter
J --> G
G -->|exhausted N-1 steps| K[write oldest → out<br/><sub>past-clamp</sub>]:::out
I --> Z[done]:::done
F --> Z
K --> Z
classDef start fill:#e0f2fe,stroke:#0284c7
classDef trivial fill:#f3f4f6,stroke:#6b7280
classDef core fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef walk fill:#fef3c7,stroke:#d97706,color:#78350f
classDef iter fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef lerp fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
classDef out fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef done fill:#f0fdf4,stroke:#16a34a,color:#14532dKey invariants:
- Recorded timestamps are monotonically non-decreasing. Out-of-order writes (network jitter, clock corrections) are silently clamped up to the current
lastTime. This guarantees that the bracketing search insample()always terminates correctly. - The walk treats
newestIdxas a rolling cursor — at every step, it represents the more-recent of the two adjacent samples being compared, so the lerp pair is always already loaded. candidateOldestIdxis the past-clamp fallback target, updated on every walk step. When the loop runs out of frames without finding a bracket, this points to the oldest retained frame.- The hot loop's body is pure scalar math: integer mod, two array reads, one subtract, one divide, one multiply-add per stride slot.
🏗️ Buffer layout
A HistoryBuffer({ capacity: 4, stride: 2 }) allocates a 12-element Float32Array laid out as:
slot 0: [ time₀ | v0₀ | v1₀ ]
slot 1: [ time₁ | v0₁ | v1₁ ]
slot 2: [ time₂ | v0₂ | v1₂ ]
slot 3: [ time₃ | v0₃ | v1₃ ]
↑ ↑ ↑
frame stride valuesInternal cursors:
| Field | Meaning |
|---|---|
| head | Index of the slot the next record() will write to. |
| count | Number of populated frames; saturates at capacity. |
| lastTime | Highest timestamp ever passed to record(); used to clamp out-of-order writes. |
The newest valid frame is at (head - 1 + capacity) % capacity. Once count === capacity, the oldest valid frame is at head itself (the slot that's about to be overwritten next).
The buffer property is exposed publicly so advanced consumers (compute kernels, batched lerpers, ECS world serializers) can read frames directly without going through sample(). Mutating it desyncs the cursors — treat it as read-only by convention.
📊 Comparison
| | lite-history-buffer | naïve [] of objects | Map<time, value> | circular-buffer (npm) |
|---|---|---|---|---|
| Hot-path allocations | 0 | 1 per record + reshuffle on shift | 2+ per set | varies |
| Backing storage | Float32Array | array of plain objects | hash table | varies |
| Built-in time interpolation | yes (linear) | manual | manual | no |
| Monotonic-time clamp | yes | manual | manual | no |
| ECS / SoA friendly | yes | no | no | no |
| Cache-locality | excellent (packed) | poor (object pointers) | poor (hash buckets) | varies |
| TypeScript types | yes (full) | n/a | n/a | varies |
| Bundle (min+gzip) | <1 KB | 0 | 0 | varies |
lite-history-buffer is not trying to be a general-purpose timeseries database. It's the kernel that does one thing well: keep the last N timestamped snapshots and let you sample any past moment without bothering the GC.
⚙️ API
new HistoryBuffer(config)
new HistoryBuffer({ capacity: number, stride: number })| Param | Type | Description |
|---|---|---|
| capacity | number | Max snapshots retained. Must be > 0. |
| stride | number | Values per snapshot. Must be >= 0. record() accepts up to 4 discrete values; for higher strides write to buffer directly. |
Throws Error if capacity <= 0 or stride < 0.
record(time, v0?, v1?, v2?, v3?): void
Record a snapshot at the given time.
| Param | Type | Description |
|---|---|---|
| time | number | Timestamp. If less than lastTime, silently clamped up. |
| v0…v3 | number? | Up to 4 discrete values. Unused slots default to 0. |
Zero allocations.
sample(targetTime, out): void
Sample the buffer at targetTime, writing into out.
| Param | Type | Description |
|---|---|---|
| targetTime | number | Time to sample at. |
| out | Float32Array \| number[] | Caller-owned destination. Must have length >= stride. Only [0..stride-1] are written. |
Behaviour:
- Empty buffer →
outuntouched. targetTime >= newest→ newest frame written verbatim.targetTime <= oldest→ oldest frame written verbatim.- Otherwise → component-wise linear interpolation between the bracketing frames.
Zero allocations.
Public properties
readonly capacity: number; // as configured
readonly stride: number; // as configured
readonly frameSize: number; // stride + 1
readonly buffer: Float32Array; // raw backing storage⚡ Performance characteristics
| Operation | Cost |
|---|---|
| record() | 1 modulo, 1 max, 1 + stride array writes — single-digit ns |
| sample() near-newest (typical) | 1 comparison, ≤2 array reads + lerp |
| sample() deep / past-clamp | linear walk over up to count - 1 frames |
| sample() empty buffer | 1 comparison |
| Memory footprint | capacity × (stride + 1) × 4 bytes |
| Total allocations | one (the Float32Array, at construction) |
| Hidden classes | one stable shape per HistoryBuffer instance |
🛡️ Validation
This library trusts its inputs. The constructor validates capacity and stride; everything else is "garbage in, garbage out" by design:
| Input | Behaviour |
|---|---|
| capacity <= 0 or stride < 0 | Constructor throws. |
| time less than lastTime | Silently clamped up to lastTime. |
| time === NaN | Propagates to lastTime; subsequent samples will degrade to past-clamp. |
| stride > 4 | Accepted, but record() only writes the first 4 values. |
| out.length < stride (number array) | The array is grown silently (still zero-allocation if you reuse it). |
| out.length < stride (Float32Array) | Trailing writes are silently dropped. |
Validation belongs at the boundary of your application — at the network parser, at the input handler — not at the bottom of every render tick.
Time precision
Timestamps are stored as Float32. For typical relative-time use (deltas of seconds or minutes) this is more than precise enough. For absolute performance.now() timestamps after many hours of continuous runtime, sub-millisecond precision degrades — pass relative time, or store time as a separate Float64Array if you need that range.
📦 TypeScript
Full TypeScript declarations included in HistoryBuffer.d.ts.
import { HistoryBuffer } from '@zakkster/lite-history-buffer';
import type { HistoryBufferConfig } from '@zakkster/lite-history-buffer';
const config: HistoryBufferConfig = { capacity: 60, stride: 2 };
const hb = new HistoryBuffer(config);
const out: Float32Array = new Float32Array(2);
hb.record(performance.now(), 1.0, 2.0);
hb.sample(performance.now() - 50, out);📚 LLM-friendly documentation
See llms.txt for a structured reference designed for AI coding assistants. Covers the public surface, the algorithm, integration patterns, and common pitfalls in one parseable document.
🧩 Pairs well with
@zakkster/lite-ecs— entity-component-system whose components this buffer can store history for.@zakkster/lite-cubic-bezier— once you have a sampled value, shape its curve.@zakkster/lite-ease— Penner easings for sample blending.@zakkster/lite-pointer-tracker— feed pointer events into aHistoryBufferfor gesture-window analysis.
License
MIT © Zahary Shinikchiev
