@irtio/client 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "irtio client SDK: joinRoom, owned-write batching, corrections, typed RPCs, presence, reconnection",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -19,8 +19,8 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@irtio/protocol": "0.2.0",
23
- "@irtio/schema": "0.2.0"
22
+ "@irtio/schema": "0.3.0",
23
+ "@irtio/protocol": "0.3.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@dimforge/rapier3d-compat": ">=0.20.0"
@@ -1,583 +0,0 @@
1
- import {
2
- MAX_PREDICTED_BODIES,
3
- PREDICTION_EPSILON,
4
- RESIM_DEPTH
5
- } from "./chunk-AJHN3YF6.js";
6
-
7
- // src/physics.ts
8
- var MAX_FREE_STEPS_PER_FRAME = 5;
9
- function key(collection, id) {
10
- return `${collection}\0${id}`;
11
- }
12
- var engine;
13
- var loading;
14
- async function loadEngine() {
15
- if (engine) return engine;
16
- loading ??= (async () => {
17
- const mod = await import("@dimforge/rapier3d-compat");
18
- const ns = mod.default ?? mod;
19
- await ns.init();
20
- engine = ns;
21
- return ns;
22
- })();
23
- return loading;
24
- }
25
- var PhysicsPredictor = class _PhysicsPredictor {
26
- constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
27
- this.store = store;
28
- this.options = options;
29
- this.meOf = meOf;
30
- this.rttOf = rttOf;
31
- this.tickIntervalOf = tickIntervalOf;
32
- this.log = log;
33
- this.collections = ext.collections.filter(
34
- (c) => c.kind === "entity" && c.physics !== void 0
35
- );
36
- }
37
- store;
38
- options;
39
- meOf;
40
- rttOf;
41
- tickIntervalOf;
42
- log;
43
- stats = {
44
- freeSteps: 0,
45
- resimSteps: 0,
46
- rebases: 0,
47
- snaps: 0,
48
- suppressed: 0,
49
- overCap: 0,
50
- lastResimMicros: 0
51
- };
52
- rapier;
53
- world;
54
- bodies = /* @__PURE__ */ new Map();
55
- /** Physics-backed entity collections, in schema order. */
56
- collections;
57
- warned = /* @__PURE__ */ new Set();
58
- /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
59
- overCapHigh = /* @__PURE__ */ new Map();
60
- /** Authority arrived since the last frame: rebase before free-running. */
61
- authorityDirty = false;
62
- accumulatorMs = 0;
63
- lastNow;
64
- lastFrameNow;
65
- failed = false;
66
- /**
67
- * Per owned body: the server tick its authoritative record is *from* (the tick of the last
68
- * `CORRECT` that touched it), and the per-tick prediction history — after every local step,
69
- * the body's channels are recorded under the tick that step predicted. A correction for tick T
70
- * is then judged against the client's prediction **for tick T**, not against the current head
71
- * of the simulation (which legitimately leads authority by the whole latency — comparing
72
- * against it would report the lead as misprediction).
73
- */
74
- authorityTicks = /* @__PURE__ */ new Map();
75
- predictedTicks = /* @__PURE__ */ new Map();
76
- history = /* @__PURE__ */ new Map();
77
- /** History kept per body — comfortably past the resim depth. */
78
- static HISTORY_TICKS = 32;
79
- get epsilon() {
80
- return this.options.epsilon ?? PREDICTION_EPSILON;
81
- }
82
- /** `true` once the engine is loaded and the local world exists. */
83
- get ready() {
84
- return this.world !== void 0;
85
- }
86
- /**
87
- * Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
88
- * interpolation/authority — joining is never blocked on 2.9 MB of WASM.
89
- */
90
- start() {
91
- if (this.world || this.failed) return Promise.resolve();
92
- return loadEngine().then(
93
- (rapier) => {
94
- this.rapier = rapier;
95
- const world = new rapier.World({ ...this.options.gravity });
96
- const tickMs = this.tickIntervalOf();
97
- world.timestep = this.options.timestep ?? (tickMs > 0 ? tickMs / 1e3 : 1 / 30);
98
- if (this.options.setup) this.options.setup(world, rapier);
99
- this.world = world;
100
- this.authorityDirty = true;
101
- },
102
- (err) => {
103
- this.failed = true;
104
- this.log(
105
- `irtio: physics prediction disabled \u2014 @dimforge/rapier3d-compat failed to load: ${err instanceof Error ? err.message : String(err)}`
106
- );
107
- }
108
- );
109
- }
110
- free() {
111
- this.bodies.clear();
112
- this.world?.free();
113
- this.world = void 0;
114
- }
115
- /** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
116
- reset() {
117
- this.authorityDirty = true;
118
- this.accumulatorMs = 0;
119
- this.lastNow = void 0;
120
- }
121
- /** Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one). */
122
- noteAuthority() {
123
- this.authorityDirty = true;
124
- }
125
- /** The session tells us which server tick one body's authoritative record is from. */
126
- noteAuthorityTick(collection, id, tick) {
127
- this.authorityTicks.set(key(collection, id), tick);
128
- }
129
- /** Does the local world currently simulate `collection[id]`? */
130
- has(collection, id) {
131
- return this.bodies.has(key(collection, id));
132
- }
133
- /**
134
- * Is a correction's every value within the suppression tolerance of the prediction it judges?
135
- * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
136
- * channels against `epsilon / timestep`, because a velocity disagreement matters by what it
137
- * moves in one tick.
138
- */
139
- withinEpsilon(desc, fields, patch, predicted) {
140
- const physics = desc.physics;
141
- if (!physics || fields.length === 0) return false;
142
- const eps = this.epsilon;
143
- const dt = this.world?.timestep ?? 1 / 30;
144
- const channelOf = new Map(physics.channels.map(([c, f]) => [f, c]));
145
- return fields.every((f) => {
146
- const a = predicted[f];
147
- const b = patch[f];
148
- if (typeof a !== "number" || typeof b !== "number") return false;
149
- const channel = channelOf.get(f);
150
- const velocity = channel?.startsWith("v") === true || channel?.startsWith("w") === true;
151
- const tolerance = velocity ? eps / dt : eps;
152
- return Math.abs(a - b) <= tolerance;
153
- });
154
- }
155
- /** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
156
- predictsCollection(name) {
157
- return this.collections.some((c) => c.name === name);
158
- }
159
- /**
160
- * Advances the local world to `now`. Driven by render reads (one pass per timestamp), so a
161
- * draw loop — or a bot's render sampling — is the clock; there is no timer.
162
- */
163
- frame(now) {
164
- if (!this.world || now === this.lastFrameNow && !this.authorityDirty) return;
165
- this.lastFrameNow = now;
166
- this.reconcileBodies();
167
- if (this.authorityDirty) {
168
- this.authorityDirty = false;
169
- this.rebase();
170
- this.accumulatorMs = 0;
171
- this.lastNow = now;
172
- return;
173
- }
174
- this.freeRun(now);
175
- }
176
- /**
177
- * The predicted record for `collection[id]`: the authoritative record (which already carries
178
- * local intent writes — `track()` writes through to plain state) with the body-mapped channels
179
- * replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
180
- * reads is what the wire would carry.
181
- */
182
- read(desc, id, base) {
183
- const entry = this.bodies.get(key(desc.name, id));
184
- const physics = desc.physics;
185
- if (!entry || !physics) return base;
186
- const out = { ...base };
187
- const t = entry.body.translation();
188
- const r = entry.body.rotation();
189
- const v = entry.body.linvel();
190
- const w = entry.body.angvel();
191
- for (const [channel, field] of physics.channels) {
192
- const value = channelValue(channel, t, r, v, w);
193
- out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
194
- }
195
- return out;
196
- }
197
- /**
198
- * The local world's current values for `fields` of one body — what a correction is judged
199
- * against (`previous` on the `correct` event, and the epsilon-suppression comparison).
200
- */
201
- predictedValues(desc, id, fields, atTick) {
202
- const entry = this.bodies.get(key(desc.name, id));
203
- const physics = desc.physics;
204
- if (!entry || !physics) return void 0;
205
- const remembered = atTick !== void 0 ? this.history.get(key(desc.name, id))?.get(atTick) : void 0;
206
- const t = entry.body.translation();
207
- const r = entry.body.rotation();
208
- const v = entry.body.linvel();
209
- const w = entry.body.angvel();
210
- const out = {};
211
- for (const [channel, field] of physics.channels) {
212
- if (!fields.includes(field)) continue;
213
- const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, t, r, v, w);
214
- out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
215
- }
216
- return out;
217
- }
218
- // -------------------------------------------------------------------------
219
- // Membership
220
- // -------------------------------------------------------------------------
221
- /**
222
- * Mirrors the local world's bodies onto the instances this client predicts: every physics
223
- * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
224
- * (collection order, then insertion order — the same stated iteration guarantee the server
225
- * follows, so which bodies fall over the cap is deterministic).
226
- *
227
- * Everything else — non-`predicted` collections, and `predicted` instances over the cap — gets
228
- * no body and no collider here. Those instances still render (the interpolation path reads
229
- * authoritative state directly), but nothing in the local world can touch them.
230
- */
231
- reconcileBodies() {
232
- const world = this.world;
233
- const rapier = this.rapier;
234
- if (!world || !rapier) return;
235
- const me = this.meOf();
236
- const live = /* @__PURE__ */ new Set();
237
- let nonOwned = 0;
238
- let overCap = 0;
239
- const overCapBy = /* @__PURE__ */ new Map();
240
- const cap = this.options.maxPredictedBodies ?? MAX_PREDICTED_BODIES;
241
- for (const desc of this.collections) {
242
- const coll = this.store.plainCollection(desc.name);
243
- for (const id of coll.ids()) {
244
- const owned = coll.ownerOf(id) === me && me !== "";
245
- if (!owned) {
246
- if (!desc.predicted) continue;
247
- if (nonOwned >= cap) {
248
- overCap++;
249
- overCapBy.set(desc.name, (overCapBy.get(desc.name) ?? 0) + 1);
250
- continue;
251
- }
252
- nonOwned++;
253
- }
254
- const k = key(desc.name, id);
255
- live.add(k);
256
- if (this.bodies.has(k)) continue;
257
- const record = coll.get(id);
258
- if (record) this.createBody(desc, id, record, owned);
259
- }
260
- }
261
- this.stats.overCap = overCap;
262
- this.warnOverCap(cap, overCapBy);
263
- for (const [k, entry] of [...this.bodies]) {
264
- if (live.has(k)) continue;
265
- this.bodies.delete(k);
266
- this.authorityTicks.delete(k);
267
- this.predictedTicks.delete(k);
268
- this.history.delete(k);
269
- this.world?.removeRigidBody(entry.body);
270
- }
271
- }
272
- createBody(desc, id, record, owned) {
273
- const world = this.world;
274
- const rapier = this.rapier;
275
- if (!world || !rapier) return;
276
- const factory = this.options.bodies?.[desc.name];
277
- if (!factory) {
278
- this.warnOnce(
279
- `factory:${desc.name}`,
280
- `irtio: physics.bodies.${desc.name} is missing from joinRoom({ physics }). ${desc.name} instances render by interpolation but have no body in the local world, so predicted bodies pass through them.`
281
- );
282
- return;
283
- }
284
- const physics = desc.physics;
285
- if (physics && !physics.channels.some(([c]) => c.startsWith("v"))) {
286
- this.warnOnce(
287
- `velocity:${desc.name}`,
288
- `irtio: ${desc.name} maps no velocity channels (vx/vy/vz) \u2014 corrections can only rebase what the schema carries, and predicted bodies drift without them. Map the velocity channels on collections you predict.`
289
- );
290
- }
291
- const spec = factory(rapier, record, id);
292
- if (!spec || !spec.body) return;
293
- const body = world.createRigidBody(spec.body);
294
- for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
295
- const entry = { desc, id, body, owned };
296
- this.applyRecord(entry, record);
297
- this.bodies.set(key(desc.name, id), entry);
298
- }
299
- /**
300
- * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
301
- * world right now, so a predicted body walks through them. Warn on every new high-water mark
302
- * per collection — a game that grows past the cap mid-session hears about it, and a count that
303
- * oscillates around one level does not turn the console into a log.
304
- */
305
- warnOverCap(cap, overCapBy) {
306
- for (const [name, count] of overCapBy) {
307
- if (count <= (this.overCapHigh.get(name) ?? 0)) continue;
308
- this.overCapHigh.set(name, count);
309
- this.log(
310
- `irtio: ${count} ${name} instance(s) over the ${cap}-body prediction cap. They are ABSENT from the local world, not interpolated into it: predicted bodies pass through them until the next correction snaps them back. Raise maxPredictedBodies (the cost is a world step per lead tick, not a per-body-per-frame cost), or stop predicting what nothing collides with.`
311
- );
312
- }
313
- }
314
- warnOnce(k, message) {
315
- if (this.warned.has(k)) return;
316
- this.warned.add(k);
317
- this.log(message);
318
- }
319
- // -------------------------------------------------------------------------
320
- // Rebase and free-run
321
- // -------------------------------------------------------------------------
322
- /**
323
- * The client's lead over authority, in ticks: one-way latency plus one tick of margin.
324
- * Measured (dive e2e, 2026-08-27): a full-round-trip lead was tried for the tick-aligned
325
- * replay and made release transitions *worse* (-1.4 to -1.8 u worst backwards step vs -0.6 at
326
- * half), because the extra lead grows the standing overshoot a rest-settling body has to give
327
- * back. Half the trip plus margin is where the stamp clock and the correction stream meet.
328
- */
329
- leadTicks() {
330
- const tickMs = this.timestepMs();
331
- return Math.max(1, Math.ceil(this.rttOf() / 2 / tickMs) + 1);
332
- }
333
- timestepMs() {
334
- return (this.world?.timestep ?? 1 / 30) * 1e3;
335
- }
336
- /**
337
- * Rebase + re-step: snap every predicted body to the authoritative record (server values —
338
- * `DELTA` for non-owned bodies, `CORRECT` for owned ones; body fields are never client-
339
- * written, so plain state holds exactly what the server said), then re-step the world by the
340
- * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
341
- * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
342
- * judged write) before the first of them. Bounded by the shared resim depth: an outrun lead
343
- * snaps to authority and counts (`stats.snaps`).
344
- */
345
- rebase() {
346
- const world = this.world;
347
- const rapier = this.rapier;
348
- if (!world || !rapier) return;
349
- this.stats.rebases++;
350
- for (const entry of this.bodies.values()) {
351
- const record = this.store.plainCollection(entry.desc.name).get(entry.id);
352
- if (record) this.applyRecord(entry, record);
353
- }
354
- for (const entry of this.bodies.values()) {
355
- if (!entry.owned) continue;
356
- const k = key(entry.desc.name, entry.id);
357
- this.predictedTicks.set(k, this.authorityTicks.get(k) ?? this.predictedTicks.get(k) ?? 0);
358
- }
359
- const lead = this.leadTicks();
360
- if (lead > RESIM_DEPTH) {
361
- this.stats.snaps++;
362
- return;
363
- }
364
- const replays = /* @__PURE__ */ new Map();
365
- for (const entry of this.bodies.values()) {
366
- if (!entry.owned) continue;
367
- const k = key(entry.desc.name, entry.id);
368
- replays.set(k, {
369
- frames: this.store.pendingWritePatches(entry.desc.name, entry.id),
370
- next: 0,
371
- inForce: this.store.baselineIntent(entry.desc.name, entry.id)
372
- });
373
- }
374
- const started = performance.now();
375
- for (let step = 0; step < lead; step++) {
376
- this.applyIntents(rapier, world, (k) => {
377
- const r = replays.get(k);
378
- if (!r) return void 0;
379
- const t = (this.predictedTicks.get(k) ?? 0) + 1;
380
- while (r.next < r.frames.length && r.frames[r.next].tick <= t) {
381
- const f = r.frames[r.next];
382
- r.inForce = r.inForce ? { ...r.inForce, ...f.patch } : { ...f.patch };
383
- r.next++;
384
- }
385
- return r.inForce;
386
- });
387
- world.step();
388
- this.recordStep();
389
- this.stats.resimSteps++;
390
- }
391
- this.stats.lastResimMicros = Math.round((performance.now() - started) * 1e3);
392
- }
393
- /**
394
- * The tick a `WRITE` flushed right now should be stamped with: one past the newest predicted
395
- * head across owned bodies — the first tick the new intent can affect. That puts the stamp on
396
- * the same clock as `authorityTicks` and the resim window (the server's tick stream), which is
397
- * what lets the rebase walk pending writes by tick instead of by array index. `undefined`
398
- * until the engine runs or while nothing is owned; the session falls back to its counter.
399
- */
400
- stampTick() {
401
- if (!this.world) return void 0;
402
- let head;
403
- for (const entry of this.bodies.values()) {
404
- if (!entry.owned) continue;
405
- const t = this.predictedTicks.get(key(entry.desc.name, entry.id));
406
- if (t !== void 0 && (head === void 0 || t > head)) head = t;
407
- }
408
- return head === void 0 ? void 0 : head + 1;
409
- }
410
- /** After every step: advance each owned body's prediction clock and remember its channels. */
411
- recordStep() {
412
- for (const entry of this.bodies.values()) {
413
- if (!entry.owned) continue;
414
- const physics = entry.desc.physics;
415
- if (!physics) continue;
416
- const k = key(entry.desc.name, entry.id);
417
- const tick = (this.predictedTicks.get(k) ?? 0) + 1;
418
- this.predictedTicks.set(k, tick);
419
- const t = entry.body.translation();
420
- const r = entry.body.rotation();
421
- const v = entry.body.linvel();
422
- const w = entry.body.angvel();
423
- const record = {};
424
- for (const [channel, field] of physics.channels) {
425
- record[field] = channelValue(channel, t, r, v, w);
426
- }
427
- let byTick = this.history.get(k);
428
- if (!byTick) {
429
- byTick = /* @__PURE__ */ new Map();
430
- this.history.set(k, byTick);
431
- }
432
- byTick.set(tick, record);
433
- byTick.delete(tick - _PhysicsPredictor.HISTORY_TICKS);
434
- }
435
- }
436
- freeRun(now) {
437
- const world = this.world;
438
- const rapier = this.rapier;
439
- if (!world || !rapier) return;
440
- if (this.lastNow === void 0) {
441
- this.lastNow = now;
442
- return;
443
- }
444
- this.accumulatorMs += Math.max(0, now - this.lastNow);
445
- this.lastNow = now;
446
- const dtMs = this.timestepMs();
447
- if (this.accumulatorMs > dtMs * MAX_FREE_STEPS_PER_FRAME) {
448
- this.accumulatorMs = dtMs * MAX_FREE_STEPS_PER_FRAME;
449
- }
450
- while (this.accumulatorMs >= dtMs) {
451
- this.accumulatorMs -= dtMs;
452
- this.applyIntents(rapier, world, () => void 0);
453
- world.step();
454
- this.recordStep();
455
- this.stats.freeSteps++;
456
- }
457
- }
458
- /**
459
- * Applies intents for every owned predicted body before one step: the intent in force at the
460
- * tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
461
- * current values (free-run: plain state carries the newest local intent writes, which is
462
- * correct there because free-run steps are the ticks *after* every buffered write).
463
- */
464
- applyIntents(rapier, world, frameFor) {
465
- for (const entry of this.bodies.values()) {
466
- if (!entry.owned) continue;
467
- const hook = this.options.intents?.[entry.desc.name];
468
- if (!hook) continue;
469
- const record = this.store.plainCollection(entry.desc.name).get(entry.id);
470
- if (!record) continue;
471
- const frame = frameFor(key(entry.desc.name, entry.id));
472
- hook(entry.body, frame ? { ...record, ...frame } : record, rapier, world);
473
- }
474
- }
475
- /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
476
- applyRecord(entry, record) {
477
- const physics = entry.desc.physics;
478
- if (!physics) return;
479
- const body = entry.body;
480
- const t = { ...body.translation() };
481
- const r = { ...body.rotation() };
482
- const v = { ...body.linvel() };
483
- const w = { ...body.angvel() };
484
- let setT = false;
485
- let setR = false;
486
- let setV = false;
487
- let setW = false;
488
- for (const [channel, field] of physics.channels) {
489
- const raw = record[field];
490
- if (typeof raw !== "number") continue;
491
- switch (channel) {
492
- case "x":
493
- case "y":
494
- case "z":
495
- t[channel] = raw;
496
- setT = true;
497
- break;
498
- case "qx":
499
- r.x = raw;
500
- setR = true;
501
- break;
502
- case "qy":
503
- r.y = raw;
504
- setR = true;
505
- break;
506
- case "qz":
507
- r.z = raw;
508
- setR = true;
509
- break;
510
- case "qw":
511
- r.w = raw;
512
- setR = true;
513
- break;
514
- case "vx":
515
- v.x = raw;
516
- setV = true;
517
- break;
518
- case "vy":
519
- v.y = raw;
520
- setV = true;
521
- break;
522
- case "vz":
523
- v.z = raw;
524
- setV = true;
525
- break;
526
- case "wx":
527
- w.x = raw;
528
- setW = true;
529
- break;
530
- case "wy":
531
- w.y = raw;
532
- setW = true;
533
- break;
534
- case "wz":
535
- w.z = raw;
536
- setW = true;
537
- break;
538
- }
539
- }
540
- if (setT) body.setTranslation(t, true);
541
- if (setR) body.setRotation(r, true);
542
- if (setV) body.setLinvel(v, true);
543
- if (setW) body.setAngvel(w, true);
544
- }
545
- isF32(desc, field) {
546
- const idx = desc.fieldIndex.get(field);
547
- if (idx === void 0) return false;
548
- return desc.fields[idx]?.type.kind === "f32";
549
- }
550
- };
551
- function channelValue(channel, t, r, v, w) {
552
- switch (channel) {
553
- case "x":
554
- return t.x;
555
- case "y":
556
- return t.y;
557
- case "z":
558
- return t.z;
559
- case "qx":
560
- return r.x;
561
- case "qy":
562
- return r.y;
563
- case "qz":
564
- return r.z;
565
- case "qw":
566
- return r.w;
567
- case "vx":
568
- return v.x;
569
- case "vy":
570
- return v.y;
571
- case "vz":
572
- return v.z;
573
- case "wx":
574
- return w.x;
575
- case "wy":
576
- return w.y;
577
- default:
578
- return w.z;
579
- }
580
- }
581
- export {
582
- PhysicsPredictor
583
- };