@irtio/client 0.1.0 → 0.3.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,14 @@
1
+ import {
2
+ ClientStore,
3
+ E_CONNECT_FAILED,
4
+ MAX_PREDICTED_BODIES,
5
+ PREDICTION_EPSILON,
6
+ RESIM_DEPTH,
7
+ SMOOTHING_HALF_LIFE_MS,
8
+ SMOOTHING_SNAP_UNITS,
9
+ emptyPredictionStats
10
+ } from "./chunk-7UTJ7RSF.js";
11
+
1
12
  // src/endpoint.ts
2
13
  var DEFAULT_REGION = "eu";
3
14
  var DEV_PORT = 7070;
@@ -52,15 +63,19 @@ function roomIdFromLocation() {
52
63
  return "";
53
64
  }
54
65
  }
66
+ var PER_CLIENT_PARAMS = ["role"];
55
67
  function publishRoomToLocation(roomId) {
56
68
  const loc = currentLocation();
57
69
  const history = globalThis.history;
58
70
  if (!loc) return void 0;
59
71
  let href;
72
+ let share;
60
73
  try {
61
74
  const url = new URL(loc.href);
62
75
  url.searchParams.set("room", roomId);
63
76
  href = url.href;
77
+ for (const param of PER_CLIENT_PARAMS) url.searchParams.delete(param);
78
+ share = url.href;
64
79
  } catch {
65
80
  return void 0;
66
81
  }
@@ -70,7 +85,7 @@ function publishRoomToLocation(roomId) {
70
85
  } catch {
71
86
  }
72
87
  }
73
- return href;
88
+ return share;
74
89
  }
75
90
  function linkForUrl(wsUrl, roomId) {
76
91
  try {
@@ -105,6 +120,7 @@ import {
105
120
  encodeReply,
106
121
  errorByCode,
107
122
  formatError,
123
+ readCorrectAppliedTick,
108
124
  readCorrectClientTick,
109
125
  relaySchema,
110
126
  rpcTable,
@@ -118,513 +134,292 @@ import {
118
134
  encodeFields
119
135
  } from "@irtio/schema";
120
136
 
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 };
137
+ // src/render.ts
138
+ var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
139
+ var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
140
+ function cloneRecord(value) {
141
+ const out = {};
142
+ for (const [k, v] of Object.entries(value)) {
143
+ out[k] = Array.isArray(v) ? [...v] : typeof v === "object" && v !== null ? cloneRecord(v) : v;
144
+ }
145
+ return out;
132
146
  }
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);
147
+ function lerpValue(desc, a, b, alpha) {
148
+ if (NUMERIC.has(desc.kind) && typeof a === "number" && typeof b === "number") {
149
+ const v = a + (b - a) * alpha;
150
+ return INTEGER.has(desc.kind) ? Math.round(v) : v;
151
+ }
152
+ if (desc.kind === "struct" && typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
153
+ const out = {};
154
+ for (const [name, sub] of Object.entries(desc.fields)) {
155
+ out[name] = lerpValue(sub, a[name], b[name], alpha);
149
156
  }
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);
157
+ return out;
184
158
  }
159
+ return Array.isArray(a) ? [...a] : a;
185
160
  }
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
- });
161
+ function lerpRecord(desc, a, b, alpha) {
162
+ const out = {};
163
+ for (const f of desc.fields) out[f.name] = lerpValue(f.type, a[f.name], b[f.name], alpha);
164
+ return out;
228
165
  }
229
- var ClientStore = class {
230
- constructor(ext, meOf) {
166
+ var RenderStore = class {
167
+ constructor(ext, store, meOf, now, delayMs) {
231
168
  this.ext = ext;
169
+ this.store = store;
232
170
  this.meOf = meOf;
171
+ this.now = now;
172
+ this.delayMs = delayMs;
233
173
  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
174
  this.view = this.buildView();
237
175
  }
238
176
  ext;
177
+ store;
239
178
  meOf;
240
- plain;
241
- tracked;
179
+ now;
180
+ delayMs;
181
+ /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
182
+ buffers = /* @__PURE__ */ new Map();
242
183
  descs = /* @__PURE__ */ new Map();
243
- frozen = /* @__PURE__ */ new WeakMap();
244
- /** The object handed out as `room.state`; identity survives a resync. */
184
+ /** The object handed out as `room.render`; identity survives a resync. */
245
185
  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
- }
186
+ /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
187
+ starved = 0;
188
+ /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
189
+ predictor;
190
+ // -- ingest ---------------------------------------------------------------
257
191
  /**
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`.
192
+ * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
193
+ * the world is visible immediately (the join snapshot is the render baseline, not a change to
194
+ * ease towards).
261
195
  */
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);
196
+ seedSnapshot() {
197
+ this.buffers.clear();
198
+ const t = this.now() - this.delayMs();
199
+ for (const c of this.ext.collections) {
200
+ if (c.kind !== "entity" || !c.interpolate) continue;
201
+ const coll = this.store.plain[c.name];
202
+ for (const id of [...coll.ids()]) {
203
+ this.bufferFor(c.name, id).frames.push({
204
+ t,
205
+ value: cloneRecord(coll.get(id))
206
+ });
284
207
  }
285
208
  }
286
- return out;
287
209
  }
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;
210
+ /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
211
+ recordDelta(delta) {
212
+ const t = this.now();
213
+ for (const dc of delta.collections) {
214
+ const desc = this.descs.get(dc.name);
215
+ if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
216
+ const coll = this.store.plain[dc.name];
217
+ for (const op of dc.ops) {
218
+ if (op.op === "remove") {
219
+ const buffer2 = this.buffers.get(dc.name)?.get(op.id);
220
+ if (buffer2 && buffer2.removedAt === void 0) buffer2.removedAt = t;
221
+ continue;
304
222
  }
223
+ const value = coll.get(op.id);
224
+ if (value === void 0) continue;
225
+ const buffer = this.bufferFor(dc.name, op.id);
226
+ buffer.removedAt = void 0;
227
+ buffer.frames.push({ t, value: cloneRecord(value) });
305
228
  }
306
229
  }
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
230
  }
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)`;
231
+ bufferFor(collection, id) {
232
+ let byId = this.buffers.get(collection);
233
+ if (!byId) {
234
+ byId = /* @__PURE__ */ new Map();
235
+ this.buffers.set(collection, byId);
346
236
  }
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`;
237
+ let buffer = byId.get(id);
238
+ if (!buffer) {
239
+ buffer = { frames: [], removedAt: void 0 };
240
+ byId.set(id, buffer);
350
241
  }
351
- return `${desc.name}[${id}] is owned by ${owner}, not by you (${me}); await room.requestOwnership(${JSON.stringify(desc.name)}, ${JSON.stringify(id)}) first`;
242
+ return buffer;
352
243
  }
353
- singletonHint(desc) {
354
- return `${desc.name} is a singleton and never client-owned; change it with an RPC (room.call.\u2026)`;
244
+ // -- read -----------------------------------------------------------------
245
+ renderTime() {
246
+ return this.now() - this.delayMs();
355
247
  }
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);
248
+ attachPredictor(predictor) {
249
+ this.predictor = predictor;
364
250
  }
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);
251
+ /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
252
+ get(desc, id) {
253
+ const coll = this.store.plain[desc.name];
254
+ if (this.predictor && desc.physics) {
255
+ this.predictor.frame(this.now());
256
+ if (this.predictor.has(desc.name, id)) {
257
+ const value = coll.get(id);
258
+ if (value !== void 0) return this.predictor.read(desc, id, { ...value });
381
259
  }
382
260
  }
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 });
261
+ if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
262
+ return this.store.instance(desc, id);
424
263
  }
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];
264
+ if (!desc.interpolate) return this.store.instance(desc, id);
265
+ const buffer = this.buffers.get(desc.name)?.get(id);
266
+ if (!buffer || buffer.frames.length === 0) {
267
+ return this.store.instance(desc, id);
441
268
  }
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
- }
269
+ const renderT = this.renderTime();
270
+ if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) {
271
+ this.gc(desc.name, id, buffer);
272
+ return void 0;
459
273
  }
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
- }
274
+ this.prune(buffer, renderT);
275
+ const frames = buffer.frames;
276
+ const a = frames[0];
277
+ if (renderT < a.t) return void 0;
278
+ const b = frames[1];
279
+ if (!b) {
280
+ if (renderT > a.t) this.starved++;
281
+ return cloneRecord(a.value);
505
282
  }
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
- }
283
+ const alpha = (renderT - a.t) / (b.t - a.t);
284
+ return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
285
+ }
286
+ /** Is `collection[id]` visible at the render clock? */
287
+ has(desc, id) {
288
+ const coll = this.store.plain[desc.name];
289
+ if (this.predictor && desc.physics && this.predictor.has(desc.name, id)) return coll.has(id);
290
+ if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) return true;
291
+ if (!desc.interpolate) return coll.has(id);
292
+ const buffer = this.buffers.get(desc.name)?.get(id);
293
+ if (!buffer || buffer.frames.length === 0) return coll.has(id);
294
+ const renderT = this.renderTime();
295
+ if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
296
+ return buffer.frames[0].t <= renderT;
297
+ }
298
+ /** The authoritative owner — ownership is fact, not a rendered value. */
299
+ ownerOf(desc, id) {
300
+ return this.store.plain[desc.name].ownerOf(id);
301
+ }
302
+ /** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
303
+ *ids(desc) {
304
+ const coll = this.store.plain[desc.name];
305
+ if (!desc.interpolate) {
306
+ yield* coll.ids();
307
+ return;
516
308
  }
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);
309
+ const seen = /* @__PURE__ */ new Set();
310
+ for (const id of [...coll.ids()]) {
311
+ if (this.has(desc, id)) {
312
+ seen.add(id);
313
+ yield id;
566
314
  }
567
315
  }
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;
316
+ const byId = this.buffers.get(desc.name);
317
+ if (!byId) return;
318
+ for (const id of [...byId.keys()]) {
319
+ if (!seen.has(id) && !coll.has(id) && this.has(desc, id)) yield id;
573
320
  }
574
321
  }
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;
322
+ /** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
323
+ prune(buffer, renderT) {
324
+ const frames = buffer.frames;
325
+ let drop = 0;
326
+ while (drop + 1 < frames.length && frames[drop + 1].t <= renderT) drop++;
327
+ if (drop > 0) frames.splice(0, drop);
587
328
  }
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;
329
+ gc(collection, id, buffer) {
330
+ buffer.frames.length = 0;
331
+ const byId = this.buffers.get(collection);
332
+ byId?.delete(id);
333
+ if (byId && byId.size === 0) this.buffers.delete(collection);
334
+ }
335
+ // -- view -----------------------------------------------------------------
336
+ buildView() {
337
+ const view = {};
595
338
  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 });
339
+ if (c.kind === "entity") {
340
+ const facade = withRenderIndexSugar(new RenderCollectionImpl(this, c));
341
+ Object.defineProperty(view, c.name, { get: () => facade, enumerable: true });
342
+ } else {
343
+ Object.defineProperty(view, c.name, {
344
+ get: () => this.store.view[c.name],
345
+ enumerable: true
346
+ });
604
347
  }
605
348
  }
349
+ return view;
606
350
  }
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);
351
+ };
352
+ var RenderCollectionImpl = class {
353
+ constructor(render, desc) {
354
+ this.render = render;
355
+ this.desc = desc;
356
+ }
357
+ render;
358
+ desc;
359
+ get(id) {
360
+ return this.render.get(this.desc, id);
361
+ }
362
+ has(id) {
363
+ return this.render.has(this.desc, id);
364
+ }
365
+ ownerOf(id) {
366
+ return this.render.ownerOf(this.desc, id);
367
+ }
368
+ get size() {
369
+ let n = 0;
370
+ for (const _ of this.render.ids(this.desc)) n++;
371
+ return n;
372
+ }
373
+ ids() {
374
+ return this.render.ids(this.desc);
375
+ }
376
+ *[Symbol.iterator]() {
377
+ for (const id of [...this.render.ids(this.desc)]) {
378
+ yield [id, this.render.get(this.desc, id)];
624
379
  }
625
- return out;
626
380
  }
627
381
  };
382
+ function withRenderIndexSugar(collection) {
383
+ return new Proxy(collection, {
384
+ get(target, prop, receiver) {
385
+ if (typeof prop !== "string" || prop in target) return Reflect.get(target, prop, receiver);
386
+ return target.get(prop);
387
+ },
388
+ has(target, prop) {
389
+ if (typeof prop !== "string" || prop in target) return Reflect.has(target, prop);
390
+ return target.has(prop);
391
+ }
392
+ });
393
+ }
394
+
395
+ // src/scheduler.ts
396
+ function rafPair() {
397
+ const g = globalThis;
398
+ if (typeof g.requestAnimationFrame !== "function") return void 0;
399
+ const request = g.requestAnimationFrame.bind(globalThis);
400
+ const cancel = (g.cancelAnimationFrame ?? (() => {
401
+ })).bind(globalThis);
402
+ return { request, cancel };
403
+ }
404
+ function defaultScheduler() {
405
+ const raf = rafPair();
406
+ const base = {
407
+ now: () => Date.now(),
408
+ setTimeout(fn, ms) {
409
+ const handle = setTimeout(fn, ms);
410
+ handle.unref?.();
411
+ return () => clearTimeout(handle);
412
+ }
413
+ };
414
+ if (!raf) return base;
415
+ return {
416
+ ...base,
417
+ frame(fn) {
418
+ const handle = raf.request(() => fn());
419
+ return () => raf.cancel(handle);
420
+ }
421
+ };
422
+ }
628
423
 
629
424
  // src/transport.ts
630
425
  function globalWebSocket() {
@@ -691,7 +486,8 @@ var webSocketTransport = {
691
486
 
692
487
  // src/session.ts
693
488
  var DEFAULT_WRITE_INTERVAL_MS = 50;
694
- var PING_INTERVAL_MS = 1e4;
489
+ var PING_INTERVAL_MS = 2e3;
490
+ var RTT_ALPHA = 0.3;
695
491
  var CALL_TIMEOUT_MS = 1e4;
696
492
  var BACKOFF_START_MS = 250;
697
493
  var BACKOFF_MAX_MS = 5e3;
@@ -717,10 +513,49 @@ var Session = class {
717
513
  this.roomId = options.roomId;
718
514
  this.role = options.role ?? "";
719
515
  this.store = new ClientStore(this.ext, () => this.me);
516
+ this.render = new RenderStore(
517
+ this.ext,
518
+ this.store,
519
+ () => this.me,
520
+ () => this.scheduler.now(),
521
+ () => this.interpDelayMs
522
+ );
523
+ const hasPhysics = this.ext.collections.some(
524
+ (c) => c.physics !== void 0
525
+ );
526
+ if (options.physics && hasPhysics) {
527
+ const physics = options.physics;
528
+ this.predictionRequested = true;
529
+ void import("./physics-H2VDQLAU.js").then(({ PhysicsPredictor }) => {
530
+ if (this.left) return;
531
+ const predictor = new PhysicsPredictor(
532
+ this.ext,
533
+ this.store,
534
+ physics,
535
+ () => this.me,
536
+ () => this.rtt,
537
+ () => this.tickIntervalMs
538
+ );
539
+ this.predictor = predictor;
540
+ this.render.attachPredictor(predictor);
541
+ if (this.joined) {
542
+ predictor.reset();
543
+ void predictor.start();
544
+ }
545
+ });
546
+ }
720
547
  }
721
548
  options;
722
549
  ext;
723
550
  store;
551
+ render;
552
+ /** `true` when the join passed `physics` and the schema has body-backed collections. */
553
+ predictionRequested = false;
554
+ /**
555
+ * Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
556
+ * engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
557
+ */
558
+ predictor;
724
559
  /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
725
560
  rpcSchema;
726
561
  transport;
@@ -732,9 +567,11 @@ var Session = class {
732
567
  roomId;
733
568
  tick = 0;
734
569
  /**
735
- * The client-local write counter (D19): +1 per flushed `WRITE`, stamped in its delta header.
570
+ * The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
571
+ * prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
572
+ * what lets the rebase replay pending writes by tick — and otherwise a bare counter.
736
573
  * Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
737
- * for this client survives the grace window too.
574
+ * for this client survives the grace window too, and only ever moves forward.
738
575
  */
739
576
  writeTick = 0;
740
577
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
@@ -742,6 +579,19 @@ var Session = class {
742
579
  rtt = 0;
743
580
  status = "connecting";
744
581
  socket;
582
+ /**
583
+ * Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
584
+ * `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
585
+ *
586
+ * Needed because a reconnecting session holds a socket that exists and is *not* sendable:
587
+ * `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
588
+ * `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
589
+ * test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
590
+ * connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
591
+ * where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
592
+ * window to be wide; it never opens against a local dev server.
593
+ */
594
+ socketOpen = false;
745
595
  resumeToken;
746
596
  joined = false;
747
597
  left = false;
@@ -756,6 +606,14 @@ var Session = class {
756
606
  linkOverride;
757
607
  settleJoin;
758
608
  failJoin;
609
+ /**
610
+ * The `room.render` delay (D20): the explicit option, else twice the room's tick interval
611
+ * (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
612
+ */
613
+ get interpDelayMs() {
614
+ if (this.options.interpDelayMs !== void 0) return this.options.interpDelayMs;
615
+ return Math.max(50, 2 * this.tickIntervalMs);
616
+ }
759
617
  // -------------------------------------------------------------------------
760
618
  // Lifecycle
761
619
  // -------------------------------------------------------------------------
@@ -779,8 +637,11 @@ var Session = class {
779
637
  return;
780
638
  }
781
639
  this.socket = socket;
640
+ this.socketOpen = false;
782
641
  socket.onopen = () => {
783
- if (this.socket === socket) this.sendHello();
642
+ if (this.socket !== socket) return;
643
+ this.socketOpen = true;
644
+ void this.sendHello(socket);
784
645
  };
785
646
  socket.onmessage = (bytes) => {
786
647
  if (this.socket === socket) this.onFrame(bytes);
@@ -805,11 +666,24 @@ var Session = class {
805
666
  }
806
667
  this.emit("error", { code: "E_INTERNAL", message, fatal: false });
807
668
  }
808
- sendHello() {
669
+ async sendHello(socket) {
670
+ let token;
671
+ const supplied = this.options.token;
672
+ if (supplied !== void 0) {
673
+ try {
674
+ token = typeof supplied === "function" ? await supplied() : supplied;
675
+ } catch (err) {
676
+ this.transportFailed(
677
+ err instanceof Error ? err : new Error(`token provider failed: ${String(err)}`)
678
+ );
679
+ return;
680
+ }
681
+ if (this.socket !== socket || !this.socketOpen || this.left) return;
682
+ }
809
683
  const hash8 = this.options.schema ? this.options.schema.hash8 : RELAY_HASH8;
810
684
  const hello = encodeHello({
811
685
  protocolVersion: PROTOCOL_VERSION,
812
- credential: { kind: "key", key: this.options.key },
686
+ credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
813
687
  roomId: this.roomId,
814
688
  schemaHash8: hash8,
815
689
  ...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
@@ -823,19 +697,23 @@ var Session = class {
823
697
  if (this.left) return;
824
698
  this.left = true;
825
699
  this.stopTimers();
700
+ this.predictor?.free();
826
701
  this.rejectPending(new Error("irtio: left the room"));
827
702
  this.setStatus("closed");
828
703
  if (this.socket && this.joined) this.send(FrameType.LEAVE, new Uint8Array(0));
829
704
  this.socket?.close();
830
705
  this.socket = void 0;
706
+ this.socketOpen = false;
831
707
  }
832
708
  fatal(error) {
833
709
  this.left = true;
834
710
  this.stopTimers();
711
+ this.predictor?.free();
835
712
  this.rejectPending(error);
836
713
  this.setStatus("closed");
837
714
  this.socket?.close();
838
715
  this.socket = void 0;
716
+ this.socketOpen = false;
839
717
  const fail = this.failJoin;
840
718
  this.settleJoin = void 0;
841
719
  this.failJoin = void 0;
@@ -843,6 +721,7 @@ var Session = class {
843
721
  }
844
722
  onSocketClosed(info) {
845
723
  this.socket = void 0;
724
+ this.socketOpen = false;
846
725
  this.stopTimers();
847
726
  if (!this.joined) {
848
727
  const detail = info?.code !== void 0 ? ` (code ${info.code}${info.reason ? `: ${info.reason}` : ""})` : "";
@@ -875,9 +754,10 @@ var Session = class {
875
754
  // Frames
876
755
  // -------------------------------------------------------------------------
877
756
  send(type, payload) {
757
+ if (!this.socketOpen || !this.socket) return;
878
758
  const frame = encodeFrame(type, payload);
879
759
  this.options.onFrame?.("out", type, frame);
880
- this.socket?.send(frame);
760
+ this.socket.send(frame);
881
761
  }
882
762
  onFrame(bytes) {
883
763
  let type;
@@ -941,6 +821,11 @@ var Session = class {
941
821
  if (welcome.roomId !== "") this.roomId = welcome.roomId;
942
822
  this.store.loadSnapshot(welcome.snapshot);
943
823
  this.store.applyPendingWrites(pending);
824
+ this.render.seedSnapshot();
825
+ if (this.predictor) {
826
+ this.predictor.reset();
827
+ void this.predictor.start();
828
+ }
944
829
  this.attempt = 0;
945
830
  if (this.options.publishLocation) {
946
831
  this.linkOverride = publishRoomToLocation(this.roomId);
@@ -957,16 +842,74 @@ var Session = class {
957
842
  const delta = decodeDelta(this.ext, payload);
958
843
  this.tick = delta.tick;
959
844
  this.store.applyServerDelta(delta);
845
+ this.render.recordDelta(delta);
846
+ if (this.predictor && delta.collections.some((dc) => this.predictor?.predictsCollection(dc.name))) {
847
+ this.predictor.noteAuthority(delta.tick);
848
+ this.predictor.frame(this.scheduler.now());
849
+ }
960
850
  }
961
851
  onCorrect(payload) {
962
852
  const r = new ByteReader(payload);
963
853
  const delta = decodeDeltaFrom(this.ext, r);
964
854
  const clientTick = readCorrectClientTick(r);
855
+ const appliedTick = readCorrectAppliedTick(r);
856
+ if (clientTick !== void 0 && appliedTick !== void 0) {
857
+ this.predictor?.noteWriteApplied(clientTick, appliedTick);
858
+ }
965
859
  this.tick = delta.tick;
860
+ const predicted = this.capturePredicted(delta);
861
+ let sawBodyFields = false;
966
862
  for (const op of this.store.applyCorrection(delta, clientTick)) {
967
- this.emit("correct", op);
863
+ const desc = this.descOfCollection(op.collection);
864
+ const physics = desc?.physics;
865
+ const bodyOnly = physics !== void 0 && op.fields.length > 0 && op.fields.every((f) => physics.bodyFields.has(f));
866
+ if (bodyOnly) sawBodyFields = true;
867
+ if (bodyOnly && this.predictor?.has(op.collection, op.id)) {
868
+ this.predictor.noteAuthorityTick(op.collection, op.id, delta.tick);
869
+ }
870
+ const pv = predicted.get(`${op.collection} ${op.id}`);
871
+ if (!bodyOnly || pv === void 0) {
872
+ this.emit("correct", { ...op, simulation: bodyOnly, suppressed: false });
873
+ continue;
874
+ }
875
+ const suppressed = this.predictor !== void 0 && desc !== void 0 ? this.predictor.withinEpsilon(desc, op.fields, op.patch, pv) : false;
876
+ if (suppressed && this.predictor) this.predictor.stats.suppressed++;
877
+ this.emit("correct", {
878
+ ...op,
879
+ previous: { ...op.previous, ...pv },
880
+ simulation: false,
881
+ suppressed
882
+ });
883
+ }
884
+ if (sawBodyFields && this.predictor) {
885
+ this.predictor.noteAuthority(delta.tick);
886
+ this.predictor.frame(this.scheduler.now());
968
887
  }
969
888
  }
889
+ descsByName;
890
+ descOfCollection(name) {
891
+ this.descsByName ??= new Map(
892
+ this.ext.collections.map((c) => [c.name, c])
893
+ );
894
+ return this.descsByName.get(name);
895
+ }
896
+ /** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
897
+ capturePredicted(delta) {
898
+ const out = /* @__PURE__ */ new Map();
899
+ const predictor = this.predictor;
900
+ if (!predictor || !predictor.ready) return out;
901
+ for (const dc of delta.collections) {
902
+ const desc = this.descOfCollection(dc.name);
903
+ if (!desc?.physics) continue;
904
+ for (const op of dc.ops) {
905
+ if (op.op !== "update" || !predictor.has(dc.name, op.id)) continue;
906
+ const fields = [...op.mask.fields].map((i) => desc.fields[i]?.name).filter((n) => n !== void 0);
907
+ const values = predictor.predictedValues(desc, op.id, fields, delta.tick);
908
+ if (values) out.set(`${dc.name} ${op.id}`, values);
909
+ }
910
+ }
911
+ return out;
912
+ }
970
913
  onError(payload) {
971
914
  const e = decodeErrorPayload(payload);
972
915
  const code = nameOfCode(e.code);
@@ -985,7 +928,8 @@ var Session = class {
985
928
  }
986
929
  onPong(payload) {
987
930
  const pong = decodePong(payload);
988
- this.rtt = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
931
+ const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
932
+ this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
989
933
  if (pong.serverTick > this.tick) this.tick = pong.serverTick;
990
934
  }
991
935
  onMsg(payload) {
@@ -999,7 +943,10 @@ var Session = class {
999
943
  // -------------------------------------------------------------------------
1000
944
  startTimers() {
1001
945
  if (!this.cancelFlush) this.armFlush();
1002
- if (!this.cancelPing) this.armPing();
946
+ if (!this.cancelPing) {
947
+ this.sendPing();
948
+ this.armPing();
949
+ }
1003
950
  }
1004
951
  /**
1005
952
  * The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
@@ -1026,21 +973,25 @@ var Session = class {
1026
973
  frameCancel?.();
1027
974
  };
1028
975
  }
976
+ sendPing() {
977
+ if (this.left || !this.socket) return;
978
+ this.send(FrameType.PING, encodePing({ t: this.scheduler.now() >>> 0 }));
979
+ }
1029
980
  armPing() {
1030
981
  this.cancelPing = this.scheduler.setTimeout(() => {
1031
982
  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();
983
+ this.sendPing();
984
+ if (!this.left && this.socket) this.armPing();
1035
985
  }, PING_INTERVAL_MS);
1036
986
  }
1037
987
  /** Sends every pending owned write now. */
1038
988
  flush() {
1039
- if (!this.socket || !this.joined) return;
989
+ if (!this.socketOpen || !this.joined) return;
1040
990
  if (!this.store.hasLocalWrites) return;
1041
- const payload = this.store.takeWrite(this.writeTick + 1);
991
+ const stamp = Math.max(this.writeTick + 1, this.predictor?.stampTick() ?? 0);
992
+ const payload = this.store.takeWrite(stamp);
1042
993
  if (payload) {
1043
- this.writeTick++;
994
+ this.writeTick = stamp;
1044
995
  this.send(FrameType.WRITE, payload);
1045
996
  }
1046
997
  }
@@ -1061,7 +1012,7 @@ var Session = class {
1061
1012
  } catch (err) {
1062
1013
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1063
1014
  }
1064
- if (!this.socket || this.left) {
1015
+ if (!this.socketOpen || this.left) {
1065
1016
  return Promise.reject(new Error(`irtio: ${name} failed \u2014 not connected`));
1066
1017
  }
1067
1018
  const reqId = ++this.reqId;
@@ -1253,8 +1204,11 @@ async function joinRoom(schema, options = {}) {
1253
1204
  publishLocation: fromLocation,
1254
1205
  role: options.role,
1255
1206
  name: options.name,
1207
+ token: options.token,
1256
1208
  rpc: options.rpc,
1257
1209
  writeIntervalMs: options.writeIntervalMs,
1210
+ interpDelayMs: options.interpDelayMs,
1211
+ physics: options.physics,
1258
1212
  transport: options.transport,
1259
1213
  scheduler: options.scheduler,
1260
1214
  onFrame: options.onFrame,
@@ -1287,9 +1241,23 @@ function makeRoom(session) {
1287
1241
  get state() {
1288
1242
  return session.store.view;
1289
1243
  },
1244
+ get render() {
1245
+ return session.render.view;
1246
+ },
1290
1247
  get clients() {
1291
1248
  return session.clients;
1292
1249
  },
1250
+ ...session.predictionRequested ? {
1251
+ prediction: {
1252
+ get active() {
1253
+ return session.predictor?.ready ?? false;
1254
+ },
1255
+ predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
1256
+ get stats() {
1257
+ return session.predictor?.stats ?? emptyPredictionStats();
1258
+ }
1259
+ }
1260
+ } : {},
1293
1261
  call,
1294
1262
  requestOwnership: (entity, id) => session.requestOwnership(entity, id),
1295
1263
  message: (target, bytes) => session.message(target, bytes),
@@ -1351,8 +1319,12 @@ export {
1351
1319
  DEFAULT_WRITE_INTERVAL_MS,
1352
1320
  DEV_PORT,
1353
1321
  E_CONNECT_FAILED,
1322
+ MAX_PREDICTED_BODIES,
1354
1323
  PING_INTERVAL_MS,
1324
+ PREDICTION_EPSILON,
1355
1325
  RESIM_DEPTH,
1326
+ SMOOTHING_HALF_LIFE_MS,
1327
+ SMOOTHING_SNAP_UNITS,
1356
1328
  Session,
1357
1329
  defaultScheduler,
1358
1330
  joinRelay,