@irtio/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,12 @@
1
+ import {
2
+ ClientStore,
3
+ E_CONNECT_FAILED,
4
+ MAX_PREDICTED_BODIES,
5
+ PREDICTION_EPSILON,
6
+ RESIM_DEPTH,
7
+ emptyPredictionStats
8
+ } from "./chunk-AJHN3YF6.js";
9
+
1
10
  // src/endpoint.ts
2
11
  var DEFAULT_REGION = "eu";
3
12
  var DEV_PORT = 7070;
@@ -118,513 +127,292 @@ import {
118
127
  encodeFields
119
128
  } from "@irtio/schema";
120
129
 
121
- // src/contract.ts
122
- var E_CONNECT_FAILED = "E_CONNECT_FAILED";
123
-
124
- // src/scheduler.ts
125
- function rafPair() {
126
- const g = globalThis;
127
- if (typeof g.requestAnimationFrame !== "function") return void 0;
128
- const request = g.requestAnimationFrame.bind(globalThis);
129
- const cancel = (g.cancelAnimationFrame ?? (() => {
130
- })).bind(globalThis);
131
- return { request, cancel };
130
+ // src/render.ts
131
+ var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
132
+ var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
133
+ function cloneRecord(value) {
134
+ const out = {};
135
+ for (const [k, v] of Object.entries(value)) {
136
+ out[k] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? cloneRecord(v) : v;
137
+ }
138
+ return out;
132
139
  }
133
- function defaultScheduler() {
134
- const raf = rafPair();
135
- const base = {
136
- now: () => Date.now(),
137
- setTimeout(fn, ms) {
138
- const handle = setTimeout(fn, ms);
139
- handle.unref?.();
140
- return () => clearTimeout(handle);
141
- }
142
- };
143
- if (!raf) return base;
144
- return {
145
- ...base,
146
- frame(fn) {
147
- const handle = raf.request(() => fn());
148
- return () => raf.cancel(handle);
140
+ function lerpValue(desc, a, b, alpha) {
141
+ if (NUMERIC.has(desc.kind) && typeof a === "number" && typeof b === "number") {
142
+ const v = a + (b - a) * alpha;
143
+ return INTEGER.has(desc.kind) ? Math.round(v) : v;
144
+ }
145
+ if (desc.kind === "struct" && typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
146
+ const out = {};
147
+ for (const [name, sub] of Object.entries(desc.fields)) {
148
+ out[name] = lerpValue(sub, a[name], b[name], alpha);
149
149
  }
150
- };
151
- }
152
-
153
- // src/state.ts
154
- import {
155
- applyDelta,
156
- collectionDirty,
157
- createDirtySet,
158
- createFieldMask,
159
- createState,
160
- decodeSnapshot,
161
- encodeDelta,
162
- frozenProxy,
163
- isDirtyEmpty,
164
- track
165
- } from "@irtio/schema";
166
- var RESIM_DEPTH = 20;
167
- function entityOf(state, name) {
168
- return state[name];
169
- }
170
- function subtractMask(dst, src) {
171
- for (const idx of src.fields) {
172
- const srcNested = src.nested.get(idx);
173
- const dstNested = dst.nested.get(idx);
174
- if (srcNested && dstNested) {
175
- subtractMask(dstNested, srcNested);
176
- if (dstNested.fields.size === 0) {
177
- dst.fields.delete(idx);
178
- dst.nested.delete(idx);
179
- }
180
- continue;
181
- }
182
- dst.fields.delete(idx);
183
- dst.nested.delete(idx);
150
+ return out;
184
151
  }
152
+ return Array.isArray(a) ? [...a] : a;
185
153
  }
186
- var ClientCollectionImpl = class {
187
- constructor(store, desc) {
188
- this.store = store;
189
- this.desc = desc;
190
- }
191
- store;
192
- desc;
193
- get plain() {
194
- return entityOf(this.store.plain, this.desc.name);
195
- }
196
- get(id) {
197
- return this.store.instance(this.desc, id);
198
- }
199
- has(id) {
200
- return this.plain.has(id);
201
- }
202
- ownerOf(id) {
203
- return this.plain.ownerOf(id);
204
- }
205
- get size() {
206
- return this.plain.size;
207
- }
208
- ids() {
209
- return this.plain.ids();
210
- }
211
- *[Symbol.iterator]() {
212
- for (const id of [...this.plain.ids()]) {
213
- yield [id, this.store.instance(this.desc, id)];
214
- }
215
- }
216
- };
217
- function withIndexSugar(collection) {
218
- return new Proxy(collection, {
219
- get(target, prop, receiver) {
220
- if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
221
- return target.get(prop);
222
- },
223
- has(target, prop) {
224
- if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
225
- return target.has(prop);
226
- }
227
- });
154
+ function lerpRecord(desc, a, b, alpha) {
155
+ const out = {};
156
+ for (const f of desc.fields) out[f.name] = lerpValue(f.type, a[f.name], b[f.name], alpha);
157
+ return out;
228
158
  }
229
- var ClientStore = class {
230
- constructor(ext, meOf) {
159
+ var RenderStore = class {
160
+ constructor(ext, store, meOf, now, delayMs) {
231
161
  this.ext = ext;
162
+ this.store = store;
232
163
  this.meOf = meOf;
164
+ this.now = now;
165
+ this.delayMs = delayMs;
233
166
  for (const c of ext.collections) this.descs.set(c.name, c);
234
- this.plain = createState(ext);
235
- this.tracked = track(ext, this.plain);
236
167
  this.view = this.buildView();
237
168
  }
238
169
  ext;
170
+ store;
239
171
  meOf;
240
- plain;
241
- tracked;
172
+ now;
173
+ delayMs;
174
+ /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
175
+ buffers = /* @__PURE__ */ new Map();
242
176
  descs = /* @__PURE__ */ new Map();
243
- frozen = /* @__PURE__ */ new WeakMap();
244
- /** The object handed out as `room.state`; identity survives a resync. */
177
+ /** The object handed out as `room.render`; identity survives a resync. */
245
178
  view;
246
- /** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
247
- pendingWrites = [];
248
- /** The newest write tick evicted from `pendingWrites` corrections older than it must snap. */
249
- evictedThroughTick = 0;
250
- // -- snapshot / resync -----------------------------------------------------
251
- loadSnapshot(bytes) {
252
- this.plain = decodeSnapshot(this.ext, bytes).state;
253
- this.tracked = track(this.ext, this.plain);
254
- this.pendingWrites.length = 0;
255
- this.evictedThroughTick = 0;
256
- }
179
+ /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
180
+ starved = 0;
181
+ /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
182
+ predictor;
183
+ // -- ingest ---------------------------------------------------------------
257
184
  /**
258
- * Captures the field values of every pending owned write, keyed by collection then id then
259
- * field name call this **before** `loadSnapshot` on a resync, or the edit is gone once the
260
- * old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
185
+ * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
186
+ * the world is visible immediately (the join snapshot is the render baseline, not a change to
187
+ * ease towards).
261
188
  */
262
- capturePendingWrites() {
263
- const out = /* @__PURE__ */ new Map();
264
- const me = this.meOf();
265
- if (me === "") return out;
266
- for (const [name, cd] of this.tracked.dirty) {
267
- const desc = this.desc(name);
268
- if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
269
- const coll = entityOf(this.plain, name);
270
- for (const [id, rd] of cd.updated) {
271
- if (rd.mask.fields.size === 0 || !coll.has(id) || coll.ownerOf(id) !== me) continue;
272
- const value = coll.get(id);
273
- const patch = {};
274
- for (const idx of rd.mask.fields) {
275
- const fname = desc.fields[idx]?.name;
276
- if (fname !== void 0) patch[fname] = value[fname];
277
- }
278
- let byId = out.get(name);
279
- if (!byId) {
280
- byId = /* @__PURE__ */ new Map();
281
- out.set(name, byId);
282
- }
283
- byId.set(id, patch);
189
+ seedSnapshot() {
190
+ this.buffers.clear();
191
+ const t = this.now() - this.delayMs();
192
+ for (const c of this.ext.collections) {
193
+ if (c.kind !== "entity" || !c.interpolate) continue;
194
+ const coll = this.store.plain[c.name];
195
+ for (const id of [...coll.ids()]) {
196
+ this.bufferFor(c.name, id).frames.push({
197
+ t,
198
+ value: cloneRecord(coll.get(id))
199
+ });
284
200
  }
285
201
  }
286
- return out;
287
202
  }
288
- /**
289
- * Writes a `capturePendingWrites` snapshot into the freshly loaded `plain` (still the raw
290
- * decoded values — no validation, they were already validated at the time of the original
291
- * local write) and marks every owned field dirty so the next flush resends the real edit, not
292
- * whatever the resync snapshot happened to carry for that field.
293
- */
294
- applyPendingWrites(pending) {
295
- const me = this.meOf();
296
- if (me === "" || pending.size === 0) return;
297
- for (const [name, byId] of pending) {
298
- const coll = entityOf(this.plain, name);
299
- for (const [id, patch] of byId) {
300
- if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
301
- const rec = coll.get(id);
302
- for (const [field, value] of Object.entries(patch)) {
303
- rec[field] = Array.isArray(value) ? [...value] : value;
203
+ /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
204
+ recordDelta(delta) {
205
+ const t = this.now();
206
+ for (const dc of delta.collections) {
207
+ const desc = this.descs.get(dc.name);
208
+ if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
209
+ const coll = this.store.plain[dc.name];
210
+ for (const op of dc.ops) {
211
+ if (op.op === "remove") {
212
+ const buffer2 = this.buffers.get(dc.name)?.get(op.id);
213
+ if (buffer2 && buffer2.removedAt === void 0) buffer2.removedAt = t;
214
+ continue;
304
215
  }
216
+ const value = coll.get(op.id);
217
+ if (value === void 0) continue;
218
+ const buffer = this.bufferFor(dc.name, op.id);
219
+ buffer.removedAt = void 0;
220
+ buffer.frames.push({ t, value: cloneRecord(value) });
305
221
  }
306
222
  }
307
- this.remarkOwned();
308
- }
309
- buildView() {
310
- const view = {};
311
- for (const c of this.ext.collections) {
312
- if (c.kind === "entity") {
313
- const facade = withIndexSugar(new ClientCollectionImpl(this, c));
314
- Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
315
- } else {
316
- Object.defineProperty(view, c.name, {
317
- get: () => this.freeze(this.plain[c.name], this.singletonHint(c)),
318
- enumerable: true
319
- });
320
- }
321
- }
322
- return view;
323
223
  }
324
- // -- instances ------------------------------------------------------------
325
- /** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
326
- instance(desc, id) {
327
- const coll = entityOf(this.plain, desc.name);
328
- const value = coll.get(id);
329
- if (value === void 0) return void 0;
330
- if (!desc.serverOwned && coll.ownerOf(id) === this.meOf()) {
331
- const trackedColl = this.tracked.state[desc.name];
332
- return trackedColl ? trackedColl.get(id) : value;
333
- }
334
- return this.freeze(value, this.entityHint(desc, id, coll.ownerOf(id)));
335
- }
336
- freeze(value, hint) {
337
- const cached = this.frozen.get(value);
338
- if (cached !== void 0) return cached;
339
- const proxy = frozenProxy(value, hint);
340
- this.frozen.set(value, proxy);
341
- return proxy;
342
- }
343
- entityHint(desc, id, owner) {
344
- if (desc.serverOwned) {
345
- return `${desc.name} is server-owned; change it with an RPC (room.call.\u2026)`;
224
+ bufferFor(collection, id) {
225
+ let byId = this.buffers.get(collection);
226
+ if (!byId) {
227
+ byId = /* @__PURE__ */ new Map();
228
+ this.buffers.set(collection, byId);
346
229
  }
347
- const me = this.meOf();
348
- if (owner === void 0 || owner === "") {
349
- return `${desc.name}[${id}] is owned by the server; await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
230
+ let buffer = byId.get(id);
231
+ if (!buffer) {
232
+ buffer = { frames: [], removedAt: void 0 };
233
+ byId.set(id, buffer);
350
234
  }
351
- return `${desc.name}[${id}] is owned by ${owner}, not by you (${me}); await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
235
+ return buffer;
352
236
  }
353
- singletonHint(desc) {
354
- return `${desc.name} is a singleton and never client-owned; change it with an RPC (room.call.\u2026)`;
237
+ // -- read -----------------------------------------------------------------
238
+ renderTime() {
239
+ return this.now() - this.delayMs();
355
240
  }
356
- // -- inbound frames -------------------------------------------------------
357
- /**
358
- * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
359
- * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
360
- */
361
- applyServerDelta(delta) {
362
- applyDelta(this.ext, this.plain, this.withoutOwnedUpdates(delta));
363
- this.replayOverAddEchoes(delta);
241
+ attachPredictor(predictor) {
242
+ this.predictor = predictor;
364
243
  }
365
- /**
366
- * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
367
- * client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
368
- * `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
369
- * Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
370
- * write is clamped) still wins through the snap+replay path.
371
- */
372
- replayOverAddEchoes(delta) {
373
- const me = this.meOf();
374
- if (me === "" || this.pendingWrites.length === 0) return;
375
- for (const dc of delta.collections) {
376
- const desc = this.desc(dc.name);
377
- if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
378
- for (const op of dc.ops) {
379
- if (op.op !== "add" || op.owner !== me) continue;
380
- for (const pw of this.pendingWrites) this.replayWrite(pw, dc.name, op.id);
244
+ /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
245
+ get(desc, id) {
246
+ const coll = this.store.plain[desc.name];
247
+ if (this.predictor && desc.physics) {
248
+ this.predictor.frame(this.now());
249
+ if (this.predictor.has(desc.name, id)) {
250
+ const value = coll.get(id);
251
+ if (value !== void 0) return this.predictor.read(desc, id, { ...value });
381
252
  }
382
253
  }
383
- }
384
- withoutOwnedUpdates(delta) {
385
- const me = this.meOf();
386
- let changed = false;
387
- const collections = [];
388
- for (const dc of delta.collections) {
389
- const desc = this.desc(dc.name);
390
- if (!desc || desc.kind !== "entity" || desc.serverOwned) {
391
- collections.push(dc);
392
- continue;
393
- }
394
- const coll = entityOf(this.plain, dc.name);
395
- const ops = [];
396
- for (const op of dc.ops) {
397
- if (op.op === "add") {
398
- const rewritten = this.preserveLocalWrites(desc, coll, op, me);
399
- if (rewritten !== op) changed = true;
400
- ops.push(rewritten);
401
- continue;
402
- }
403
- if (op.op !== "update") {
404
- ops.push(op);
405
- continue;
406
- }
407
- const owner = op.owner ?? coll.ownerOf(op.id);
408
- if (owner !== me) {
409
- ops.push(op);
410
- continue;
411
- }
412
- changed = true;
413
- if (op.owner !== void 0) {
414
- ops.push({
415
- op: "update",
416
- id: op.id,
417
- owner: op.owner,
418
- mask: createFieldMask(),
419
- patch: {}
420
- });
421
- }
422
- }
423
- collections.push({ name: dc.name, ops });
254
+ if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
255
+ return this.store.instance(desc, id);
424
256
  }
425
- return changed ? { tick: delta.tick, hash8: delta.hash8, collections } : delta;
426
- }
427
- /**
428
- * An `add` for an instance this client will own, with its unflushed local field values folded
429
- * back in. Returns `op` unchanged when there is nothing to preserve.
430
- */
431
- preserveLocalWrites(desc, coll, op, me) {
432
- if (me === "" || op.owner !== me) return op;
433
- const pending = this.tracked.dirty.get(desc.name)?.updated.get(op.id);
434
- if (!pending || pending.mask.fields.size === 0) return op;
435
- const local = coll.get(op.id);
436
- if (!local) return op;
437
- const value = { ...op.value };
438
- for (const idx of pending.mask.fields) {
439
- const field = desc.fields[idx]?.name;
440
- if (field !== void 0) value[field] = local[field];
257
+ if (!desc.interpolate) return this.store.instance(desc, id);
258
+ const buffer = this.buffers.get(desc.name)?.get(id);
259
+ if (!buffer || buffer.frames.length === 0) {
260
+ return this.store.instance(desc, id);
441
261
  }
442
- return { op: "add", id: op.id, owner: op.owner, value };
443
- }
444
- /**
445
- * Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
446
- * write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
447
- * the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
448
- * dirty mark survives so the next flush re-sends the *local* value, not the server's).
449
- *
450
- * Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
451
- * `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
452
- * and the local dirty marks it supersedes are cleared.
453
- */
454
- applyCorrection(delta, clientTick) {
455
- if (clientTick !== void 0) {
456
- while (this.pendingWrites.length > 0 && this.pendingWrites[0].tick <= clientTick) {
457
- this.pendingWrites.shift();
458
- }
262
+ const renderT = this.renderTime();
263
+ if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) {
264
+ this.gc(desc.name, id, buffer);
265
+ return void 0;
459
266
  }
460
- const snapped = clientTick !== void 0 && clientTick < this.evictedThroughTick;
461
- const replay = clientTick !== void 0 && !snapped;
462
- const me = this.meOf();
463
- const out = [];
464
- const targets = [];
465
- const unflushed = { tick: Number.MAX_SAFE_INTEGER, writes: /* @__PURE__ */ new Map() };
466
- for (const dc of delta.collections) {
467
- const desc = this.desc(dc.name);
468
- if (!desc) continue;
469
- for (const op of dc.ops) {
470
- if (op.op !== "update") continue;
471
- const pending = this.tracked.dirty.get(dc.name)?.updated.get(op.id);
472
- if (pending) {
473
- if (replay) {
474
- const coll = entityOf(this.plain, dc.name);
475
- const value = coll.get(op.id);
476
- if (value && coll.ownerOf(op.id) === me && pending.mask.fields.size > 0) {
477
- const patch = {};
478
- for (const idx of pending.mask.fields) {
479
- const fname = desc.fields[idx]?.name;
480
- if (fname !== void 0) patch[fname] = value[fname];
481
- }
482
- let byId = unflushed.writes.get(dc.name);
483
- if (!byId) {
484
- byId = /* @__PURE__ */ new Map();
485
- unflushed.writes.set(dc.name, byId);
486
- }
487
- byId.set(op.id, patch);
488
- }
489
- } else {
490
- subtractMask(pending.mask, op.mask);
491
- }
492
- }
493
- targets.push({ collection: dc.name, id: op.id });
494
- out.push({
495
- collection: dc.name,
496
- id: op.id,
497
- fields: [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0),
498
- patch: op.patch,
499
- tick: delta.tick,
500
- ...clientTick !== void 0 ? { clientTick } : {},
501
- replayed: 0,
502
- snapped
503
- });
504
- }
267
+ this.prune(buffer, renderT);
268
+ const frames = buffer.frames;
269
+ const a = frames[0];
270
+ if (renderT < a.t) return void 0;
271
+ const b = frames[1];
272
+ if (!b) {
273
+ if (renderT > a.t) this.starved++;
274
+ return cloneRecord(a.value);
505
275
  }
506
- applyDelta(this.ext, this.plain, delta);
507
- let replayed = 0;
508
- if (replay) {
509
- for (const pw of [...this.pendingWrites, unflushed]) {
510
- let applied = false;
511
- for (const t of targets) {
512
- if (this.replayWrite(pw, t.collection, t.id)) applied = true;
513
- }
514
- if (applied) replayed++;
515
- }
276
+ const alpha = (renderT - a.t) / (b.t - a.t);
277
+ return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
278
+ }
279
+ /** Is `collection[id]` visible at the render clock? */
280
+ has(desc, id) {
281
+ const coll = this.store.plain[desc.name];
282
+ if (this.predictor && desc.physics && this.predictor.has(desc.name, id)) return coll.has(id);
283
+ if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) return true;
284
+ if (!desc.interpolate) return coll.has(id);
285
+ const buffer = this.buffers.get(desc.name)?.get(id);
286
+ if (!buffer || buffer.frames.length === 0) return coll.has(id);
287
+ const renderT = this.renderTime();
288
+ if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
289
+ return buffer.frames[0].t <= renderT;
290
+ }
291
+ /** The authoritative owner — ownership is fact, not a rendered value. */
292
+ ownerOf(desc, id) {
293
+ return this.store.plain[desc.name].ownerOf(id);
294
+ }
295
+ /** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
296
+ *ids(desc) {
297
+ const coll = this.store.plain[desc.name];
298
+ if (!desc.interpolate) {
299
+ yield* coll.ids();
300
+ return;
516
301
  }
517
- return replayed === 0 ? out : out.map((op) => ({ ...op, replayed }));
518
- }
519
- desc(name) {
520
- return this.descs.get(name);
521
- }
522
- // -- outbound writes ------------------------------------------------------
523
- /** Cheap check for the flush loop: has anything been written locally since the last flush? */
524
- get hasLocalWrites() {
525
- return !isDirtyEmpty(this.tracked.dirty);
526
- }
527
- /**
528
- * The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
529
- * dirty set either way (a write to an instance that has since been removed or handed away is
530
- * dropped, not retried forever).
531
- */
532
- takeWrite(tick) {
533
- const dirty = this.tracked.flush();
534
- const owned = this.ownedDirty(dirty);
535
- if (owned.size === 0) return void 0;
536
- this.retainPendingWrite(tick, owned);
537
- return encodeDelta(this.ext, this.plain, owned, { tick });
538
- }
539
- /**
540
- * Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
541
- * `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
542
- * Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
543
- */
544
- retainPendingWrite(tick, owned) {
545
- const writes = /* @__PURE__ */ new Map();
546
- for (const [name, cd] of owned) {
547
- const desc = this.desc(name);
548
- if (!desc) continue;
549
- const coll = entityOf(this.plain, name);
550
- for (const [id, rd] of cd.updated) {
551
- const value = coll.get(id);
552
- if (!value) continue;
553
- const patch = {};
554
- for (const idx of rd.mask.fields) {
555
- const fname = desc.fields[idx]?.name;
556
- if (fname === void 0) continue;
557
- const v = value[fname];
558
- patch[fname] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? { ...v } : v;
559
- }
560
- let byId = writes.get(name);
561
- if (!byId) {
562
- byId = /* @__PURE__ */ new Map();
563
- writes.set(name, byId);
564
- }
565
- byId.set(id, patch);
302
+ const seen = /* @__PURE__ */ new Set();
303
+ for (const id of [...coll.ids()]) {
304
+ if (this.has(desc, id)) {
305
+ seen.add(id);
306
+ yield id;
566
307
  }
567
308
  }
568
- if (writes.size === 0) return;
569
- this.pendingWrites.push({ tick, writes });
570
- while (this.pendingWrites.length > RESIM_DEPTH) {
571
- const evicted = this.pendingWrites.shift();
572
- if (evicted && evicted.tick > this.evictedThroughTick) this.evictedThroughTick = evicted.tick;
309
+ const byId = this.buffers.get(desc.name);
310
+ if (!byId) return;
311
+ for (const id of [...byId.keys()]) {
312
+ if (!seen.has(id) && !coll.has(id) && this.has(desc, id)) yield id;
573
313
  }
574
314
  }
575
- /** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
576
- replayWrite(pw, collection, id) {
577
- const patch = pw.writes.get(collection)?.get(id);
578
- if (!patch) return false;
579
- const me = this.meOf();
580
- const coll = entityOf(this.plain, collection);
581
- if (!coll.has(id) || coll.ownerOf(id) !== me) return false;
582
- const rec = coll.get(id);
583
- for (const [field, value] of Object.entries(patch)) {
584
- rec[field] = Array.isArray(value) ? [...value] : typeof value === "object" && value !== null ? { ...value } : value;
585
- }
586
- return true;
315
+ /** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
316
+ prune(buffer, renderT) {
317
+ const frames = buffer.frames;
318
+ let drop = 0;
319
+ while (drop + 1 < frames.length && frames[drop + 1].t <= renderT) drop++;
320
+ if (drop > 0) frames.splice(0, drop);
587
321
  }
588
- /**
589
- * Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
590
- * worker restart) does not silently drop writes that were in flight.
591
- */
592
- remarkOwned() {
593
- const me = this.meOf();
594
- if (me === "") return;
322
+ gc(collection, id, buffer) {
323
+ buffer.frames.length = 0;
324
+ const byId = this.buffers.get(collection);
325
+ byId?.delete(id);
326
+ if (byId && byId.size === 0) this.buffers.delete(collection);
327
+ }
328
+ // -- view -----------------------------------------------------------------
329
+ buildView() {
330
+ const view = {};
595
331
  for (const c of this.ext.collections) {
596
- if (c.kind !== "entity" || c.serverOwned) continue;
597
- const coll = entityOf(this.plain, c.name);
598
- for (const id of [...coll.ids()]) {
599
- if (coll.ownerOf(id) !== me) continue;
600
- const rec = collectionDirty(this.tracked.dirty, c.name).updated;
601
- const mask = createFieldMask();
602
- for (const f of c.fields) mask.fields.add(f.index);
603
- rec.set(id, { mask, owner: false });
332
+ if (c.kind === "entity") {
333
+ const facade = withRenderIndexSugar(new RenderCollectionImpl(this, c));
334
+ Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
335
+ } else {
336
+ Object.defineProperty(view, c.name, {
337
+ get: () => this.store.view[c.name],
338
+ enumerable: true
339
+ });
604
340
  }
605
341
  }
342
+ return view;
606
343
  }
607
- /** Only update ops, only instances that still exist and are still mine, never the owner bit. */
608
- ownedDirty(dirty) {
609
- const me = this.meOf();
610
- const out = createDirtySet();
611
- if (me === "") return out;
612
- for (const [name, cd] of dirty) {
613
- const desc = this.desc(name);
614
- if (!desc || desc.kind !== "entity" || desc.serverOwned) continue;
615
- const coll = entityOf(this.plain, name);
616
- for (const [id, rd] of cd.updated) {
617
- if (rd.mask.fields.size === 0) continue;
618
- if (!coll.has(id) || coll.ownerOf(id) !== me) continue;
619
- collectionDirty(out, name).updated.set(id, { mask: rd.mask, owner: false });
620
- }
621
- }
622
- for (const [name, cd] of [...out]) {
623
- if (cd.updated.size === 0) out.delete(name);
344
+ };
345
+ var RenderCollectionImpl = class {
346
+ constructor(render, desc) {
347
+ this.render = render;
348
+ this.desc = desc;
349
+ }
350
+ render;
351
+ desc;
352
+ get(id) {
353
+ return this.render.get(this.desc, id);
354
+ }
355
+ has(id) {
356
+ return this.render.has(this.desc, id);
357
+ }
358
+ ownerOf(id) {
359
+ return this.render.ownerOf(this.desc, id);
360
+ }
361
+ get size() {
362
+ let n = 0;
363
+ for (const _ of this.render.ids(this.desc)) n++;
364
+ return n;
365
+ }
366
+ ids() {
367
+ return this.render.ids(this.desc);
368
+ }
369
+ *[Symbol.iterator]() {
370
+ for (const id of [...this.render.ids(this.desc)]) {
371
+ yield [id, this.render.get(this.desc, id)];
624
372
  }
625
- return out;
626
373
  }
627
374
  };
375
+ function withRenderIndexSugar(collection) {
376
+ return new Proxy(collection, {
377
+ get(target, prop, receiver) {
378
+ if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
379
+ return target.get(prop);
380
+ },
381
+ has(target, prop) {
382
+ if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
383
+ return target.has(prop);
384
+ }
385
+ });
386
+ }
387
+
388
+ // src/scheduler.ts
389
+ function rafPair() {
390
+ const g = globalThis;
391
+ if (typeof g.requestAnimationFrame !== "function") return void 0;
392
+ const request = g.requestAnimationFrame.bind(globalThis);
393
+ const cancel = (g.cancelAnimationFrame ?? (() => {
394
+ })).bind(globalThis);
395
+ return { request, cancel };
396
+ }
397
+ function defaultScheduler() {
398
+ const raf = rafPair();
399
+ const base = {
400
+ now: () => Date.now(),
401
+ setTimeout(fn, ms) {
402
+ const handle = setTimeout(fn, ms);
403
+ handle.unref?.();
404
+ return () => clearTimeout(handle);
405
+ }
406
+ };
407
+ if (!raf) return base;
408
+ return {
409
+ ...base,
410
+ frame(fn) {
411
+ const handle = raf.request(() => fn());
412
+ return () => raf.cancel(handle);
413
+ }
414
+ };
415
+ }
628
416
 
629
417
  // src/transport.ts
630
418
  function globalWebSocket() {
@@ -717,10 +505,49 @@ var Session = class {
717
505
  this.roomId = options.roomId;
718
506
  this.role = options.role ?? "";
719
507
  this.store = new ClientStore(this.ext, () => this.me);
508
+ this.render = new RenderStore(
509
+ this.ext,
510
+ this.store,
511
+ () => this.me,
512
+ () => this.scheduler.now(),
513
+ () => this.interpDelayMs
514
+ );
515
+ const hasPhysics = this.ext.collections.some(
516
+ (c) => c.physics !== void 0
517
+ );
518
+ if (options.physics && hasPhysics) {
519
+ const physics = options.physics;
520
+ this.predictionRequested = true;
521
+ void import("./physics-BBFSQEYL.js").then(({ PhysicsPredictor }) => {
522
+ if (this.left) return;
523
+ const predictor = new PhysicsPredictor(
524
+ this.ext,
525
+ this.store,
526
+ physics,
527
+ () => this.me,
528
+ () => this.rtt,
529
+ () => this.tickIntervalMs
530
+ );
531
+ this.predictor = predictor;
532
+ this.render.attachPredictor(predictor);
533
+ if (this.joined) {
534
+ predictor.reset();
535
+ void predictor.start();
536
+ }
537
+ });
538
+ }
720
539
  }
721
540
  options;
722
541
  ext;
723
542
  store;
543
+ render;
544
+ /** `true` when the join passed `physics` and the schema has body-backed collections. */
545
+ predictionRequested = false;
546
+ /**
547
+ * Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
548
+ * engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
549
+ */
550
+ predictor;
724
551
  /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
725
552
  rpcSchema;
726
553
  transport;
@@ -732,9 +559,11 @@ var Session = class {
732
559
  roomId;
733
560
  tick = 0;
734
561
  /**
735
- * The client-local write counter (D19): +1 per flushed `WRITE`, stamped in its delta header.
562
+ * The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
563
+ * prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
564
+ * what lets the rebase replay pending writes by tick — and otherwise a bare counter.
736
565
  * Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
737
- * for this client survives the grace window too.
566
+ * for this client survives the grace window too, and only ever moves forward.
738
567
  */
739
568
  writeTick = 0;
740
569
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
@@ -742,6 +571,19 @@ var Session = class {
742
571
  rtt = 0;
743
572
  status = "connecting";
744
573
  socket;
574
+ /**
575
+ * Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
576
+ * `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
577
+ *
578
+ * Needed because a reconnecting session holds a socket that exists and is *not* sendable:
579
+ * `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
580
+ * `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
581
+ * test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
582
+ * connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
583
+ * where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
584
+ * window to be wide; it never opens against a local dev server.
585
+ */
586
+ socketOpen = false;
745
587
  resumeToken;
746
588
  joined = false;
747
589
  left = false;
@@ -756,6 +598,14 @@ var Session = class {
756
598
  linkOverride;
757
599
  settleJoin;
758
600
  failJoin;
601
+ /**
602
+ * The `room.render` delay (D20): the explicit option, else twice the room's tick interval
603
+ * (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
604
+ */
605
+ get interpDelayMs() {
606
+ if (this.options.interpDelayMs !== void 0) return this.options.interpDelayMs;
607
+ return Math.max(50, 2 * this.tickIntervalMs);
608
+ }
759
609
  // -------------------------------------------------------------------------
760
610
  // Lifecycle
761
611
  // -------------------------------------------------------------------------
@@ -779,8 +629,11 @@ var Session = class {
779
629
  return;
780
630
  }
781
631
  this.socket = socket;
632
+ this.socketOpen = false;
782
633
  socket.onopen = () => {
783
- if (this.socket === socket) this.sendHello();
634
+ if (this.socket !== socket) return;
635
+ this.socketOpen = true;
636
+ void this.sendHello(socket);
784
637
  };
785
638
  socket.onmessage = (bytes) => {
786
639
  if (this.socket === socket) this.onFrame(bytes);
@@ -805,11 +658,24 @@ var Session = class {
805
658
  }
806
659
  this.emit("error", { code: "E_INTERNAL", message, fatal: false });
807
660
  }
808
- sendHello() {
661
+ async sendHello(socket) {
662
+ let token;
663
+ const supplied = this.options.token;
664
+ if (supplied !== void 0) {
665
+ try {
666
+ token = typeof supplied === "function" ? await supplied() : supplied;
667
+ } catch (err) {
668
+ this.transportFailed(
669
+ err instanceof Error ? err : new Error(`token provider failed: ${String(err)}`)
670
+ );
671
+ return;
672
+ }
673
+ if (this.socket !== socket || !this.socketOpen || this.left) return;
674
+ }
809
675
  const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
810
676
  const hello = encodeHello({
811
677
  protocolVersion: PROTOCOL_VERSION,
812
- credential: { kind: "key", key: this.options.key },
678
+ credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
813
679
  roomId: this.roomId,
814
680
  schemaHash8: hash8,
815
681
  ...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
@@ -823,19 +689,23 @@ var Session = class {
823
689
  if (this.left) return;
824
690
  this.left = true;
825
691
  this.stopTimers();
692
+ this.predictor?.free();
826
693
  this.rejectPending(new Error("irtio: left the room"));
827
694
  this.setStatus("closed");
828
695
  if (this.socket && this.joined) this.send(FrameType.LEAVE, new Uint8Array(0));
829
696
  this.socket?.close();
830
697
  this.socket = void 0;
698
+ this.socketOpen = false;
831
699
  }
832
700
  fatal(error) {
833
701
  this.left = true;
834
702
  this.stopTimers();
703
+ this.predictor?.free();
835
704
  this.rejectPending(error);
836
705
  this.setStatus("closed");
837
706
  this.socket?.close();
838
707
  this.socket = void 0;
708
+ this.socketOpen = false;
839
709
  const fail = this.failJoin;
840
710
  this.settleJoin = void 0;
841
711
  this.failJoin = void 0;
@@ -843,6 +713,7 @@ var Session = class {
843
713
  }
844
714
  onSocketClosed(info) {
845
715
  this.socket = void 0;
716
+ this.socketOpen = false;
846
717
  this.stopTimers();
847
718
  if (!this.joined) {
848
719
  const detail = info?.code !== void 0 ? ` (code ${info.code}${info.reason ? `: ${info.reason}` : ""})` : "";
@@ -875,9 +746,10 @@ var Session = class {
875
746
  // Frames
876
747
  // -------------------------------------------------------------------------
877
748
  send(type, payload) {
749
+ if (!this.socketOpen || !this.socket) return;
878
750
  const frame = encodeFrame(type, payload);
879
751
  this.options.onFrame?.("out", type, frame);
880
- this.socket?.send(frame);
752
+ this.socket.send(frame);
881
753
  }
882
754
  onFrame(bytes) {
883
755
  let type;
@@ -941,6 +813,11 @@ var Session = class {
941
813
  if (welcome.roomId !== "") this.roomId = welcome.roomId;
942
814
  this.store.loadSnapshot(welcome.snapshot);
943
815
  this.store.applyPendingWrites(pending);
816
+ this.render.seedSnapshot();
817
+ if (this.predictor) {
818
+ this.predictor.reset();
819
+ void this.predictor.start();
820
+ }
944
821
  this.attempt = 0;
945
822
  if (this.options.publishLocation) {
946
823
  this.linkOverride = publishRoomToLocation(this.roomId);
@@ -957,16 +834,70 @@ var Session = class {
957
834
  const delta = decodeDelta(this.ext, payload);
958
835
  this.tick = delta.tick;
959
836
  this.store.applyServerDelta(delta);
837
+ this.render.recordDelta(delta);
838
+ if (this.predictor && delta.collections.some((dc) => this.predictor?.predictsCollection(dc.name))) {
839
+ this.predictor.noteAuthority();
840
+ this.predictor.frame(this.scheduler.now());
841
+ }
960
842
  }
961
843
  onCorrect(payload) {
962
844
  const r = new ByteReader(payload);
963
845
  const delta = decodeDeltaFrom(this.ext, r);
964
846
  const clientTick = readCorrectClientTick(r);
965
847
  this.tick = delta.tick;
848
+ const predicted = this.capturePredicted(delta);
849
+ let sawBodyFields = false;
966
850
  for (const op of this.store.applyCorrection(delta, clientTick)) {
967
- this.emit("correct", op);
851
+ const desc = this.descOfCollection(op.collection);
852
+ const physics = desc?.physics;
853
+ const bodyOnly = physics !== void 0 && op.fields.length > 0 && op.fields.every((f) => physics.bodyFields.has(f));
854
+ if (bodyOnly) sawBodyFields = true;
855
+ if (bodyOnly && this.predictor?.has(op.collection, op.id)) {
856
+ this.predictor.noteAuthorityTick(op.collection, op.id, delta.tick);
857
+ }
858
+ const pv = predicted.get(`${op.collection} ${op.id}`);
859
+ if (!bodyOnly || pv === void 0) {
860
+ this.emit("correct", { ...op, simulation: bodyOnly, suppressed: false });
861
+ continue;
862
+ }
863
+ const suppressed = this.predictor !== void 0 && desc !== void 0 ? this.predictor.withinEpsilon(desc, op.fields, op.patch, pv) : false;
864
+ if (suppressed && this.predictor) this.predictor.stats.suppressed++;
865
+ this.emit("correct", {
866
+ ...op,
867
+ previous: { ...op.previous, ...pv },
868
+ simulation: false,
869
+ suppressed
870
+ });
871
+ }
872
+ if (sawBodyFields && this.predictor) {
873
+ this.predictor.noteAuthority();
874
+ this.predictor.frame(this.scheduler.now());
968
875
  }
969
876
  }
877
+ descsByName;
878
+ descOfCollection(name) {
879
+ this.descsByName ??= new Map(
880
+ this.ext.collections.map((c) => [c.name, c])
881
+ );
882
+ return this.descsByName.get(name);
883
+ }
884
+ /** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
885
+ capturePredicted(delta) {
886
+ const out = /* @__PURE__ */ new Map();
887
+ const predictor = this.predictor;
888
+ if (!predictor || !predictor.ready) return out;
889
+ for (const dc of delta.collections) {
890
+ const desc = this.descOfCollection(dc.name);
891
+ if (!desc?.physics) continue;
892
+ for (const op of dc.ops) {
893
+ if (op.op !== "update" || !predictor.has(dc.name, op.id)) continue;
894
+ const fields = [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0);
895
+ const values = predictor.predictedValues(desc, op.id, fields, delta.tick);
896
+ if (values) out.set(`${dc.name} ${op.id}`, values);
897
+ }
898
+ }
899
+ return out;
900
+ }
970
901
  onError(payload) {
971
902
  const e = decodeErrorPayload(payload);
972
903
  const code = nameOfCode(e.code);
@@ -999,7 +930,10 @@ var Session = class {
999
930
  // -------------------------------------------------------------------------
1000
931
  startTimers() {
1001
932
  if (!this.cancelFlush) this.armFlush();
1002
- if (!this.cancelPing) this.armPing();
933
+ if (!this.cancelPing) {
934
+ this.sendPing();
935
+ this.armPing();
936
+ }
1003
937
  }
1004
938
  /**
1005
939
  * The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
@@ -1026,21 +960,25 @@ var Session = class {
1026
960
  frameCancel?.();
1027
961
  };
1028
962
  }
963
+ sendPing() {
964
+ if (this.left || !this.socket) return;
965
+ this.send(FrameType.PING, encodePing({ t: this.scheduler.now() >>> 0 }));
966
+ }
1029
967
  armPing() {
1030
968
  this.cancelPing = this.scheduler.setTimeout(() => {
1031
969
  this.cancelPing = void 0;
1032
- if (this.left || !this.socket) return;
1033
- this.send(FrameType.PING, encodePing({ t: this.scheduler.now() >>> 0 }));
1034
- this.armPing();
970
+ this.sendPing();
971
+ if (!this.left && this.socket) this.armPing();
1035
972
  }, PING_INTERVAL_MS);
1036
973
  }
1037
974
  /** Sends every pending owned write now. */
1038
975
  flush() {
1039
- if (!this.socket || !this.joined) return;
976
+ if (!this.socketOpen || !this.joined) return;
1040
977
  if (!this.store.hasLocalWrites) return;
1041
- const payload = this.store.takeWrite(this.writeTick + 1);
978
+ const stamp = Math.max(this.writeTick + 1, this.predictor?.stampTick() ?? 0);
979
+ const payload = this.store.takeWrite(stamp);
1042
980
  if (payload) {
1043
- this.writeTick++;
981
+ this.writeTick = stamp;
1044
982
  this.send(FrameType.WRITE, payload);
1045
983
  }
1046
984
  }
@@ -1061,7 +999,7 @@ var Session = class {
1061
999
  } catch (err) {
1062
1000
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1063
1001
  }
1064
- if (!this.socket || this.left) {
1002
+ if (!this.socketOpen || this.left) {
1065
1003
  return Promise.reject(new Error(`irtio: ${name} failed \u2014 not connected`));
1066
1004
  }
1067
1005
  const reqId = ++this.reqId;
@@ -1253,8 +1191,11 @@ async function joinRoom(schema, options = {}) {
1253
1191
  publishLocation: fromLocation,
1254
1192
  role: options.role,
1255
1193
  name: options.name,
1194
+ token: options.token,
1256
1195
  rpc: options.rpc,
1257
1196
  writeIntervalMs: options.writeIntervalMs,
1197
+ interpDelayMs: options.interpDelayMs,
1198
+ physics: options.physics,
1258
1199
  transport: options.transport,
1259
1200
  scheduler: options.scheduler,
1260
1201
  onFrame: options.onFrame,
@@ -1287,9 +1228,23 @@ function makeRoom(session) {
1287
1228
  get state() {
1288
1229
  return session.store.view;
1289
1230
  },
1231
+ get render() {
1232
+ return session.render.view;
1233
+ },
1290
1234
  get clients() {
1291
1235
  return session.clients;
1292
1236
  },
1237
+ ...session.predictionRequested ? {
1238
+ prediction: {
1239
+ get active() {
1240
+ return session.predictor?.ready ?? false;
1241
+ },
1242
+ predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
1243
+ get stats() {
1244
+ return session.predictor?.stats ?? emptyPredictionStats();
1245
+ }
1246
+ }
1247
+ } : {},
1293
1248
  call,
1294
1249
  requestOwnership: (entity, id) => session.requestOwnership(entity, id),
1295
1250
  message: (target, bytes) => session.message(target, bytes),
@@ -1351,7 +1306,9 @@ export {
1351
1306
  DEFAULT_WRITE_INTERVAL_MS,
1352
1307
  DEV_PORT,
1353
1308
  E_CONNECT_FAILED,
1309
+ MAX_PREDICTED_BODIES,
1354
1310
  PING_INTERVAL_MS,
1311
+ PREDICTION_EPSILON,
1355
1312
  RESIM_DEPTH,
1356
1313
  Session,
1357
1314
  defaultScheduler,