@irtio/client 0.2.0 → 0.4.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.
@@ -0,0 +1,1059 @@
1
+ import {
2
+ MAX_PREDICTED_BODIES,
3
+ PREDICTION_EPSILON,
4
+ RESIM_DEPTH,
5
+ SMOOTHING_HALF_LIFE_MS,
6
+ SMOOTHING_SNAP_UNITS
7
+ } from "./chunk-7UTJ7RSF.js";
8
+
9
+ // src/physics.ts
10
+ var MAX_FREE_STEPS_PER_FRAME = 5;
11
+ var FREE_RUN_GRACE = 0.5;
12
+ var POSE_RING = 4;
13
+ var LAG_TARGET_MIN = 0.7;
14
+ var LAG_TARGET_MAX = 1.4;
15
+ var CLOCK_TRIM = 0.12;
16
+ var GAP_ALPHA = 0.3;
17
+ function emptyPose() {
18
+ return {
19
+ t: { x: 0, y: 0, z: 0 },
20
+ r: { x: 0, y: 0, z: 0, w: 1 },
21
+ v: { x: 0, y: 0, z: 0 },
22
+ w: { x: 0, y: 0, z: 0 }
23
+ };
24
+ }
25
+ function readPose(body, into) {
26
+ const t = body.translation();
27
+ const r = body.rotation();
28
+ const v = body.linvel();
29
+ const w = body.angvel();
30
+ into.t.x = t.x;
31
+ into.t.y = t.y;
32
+ into.t.z = t.z;
33
+ into.r.x = r.x;
34
+ into.r.y = r.y;
35
+ into.r.z = r.z;
36
+ into.r.w = r.w;
37
+ into.v.x = v.x;
38
+ into.v.y = v.y;
39
+ into.v.z = v.z;
40
+ into.w.x = w.x;
41
+ into.w.y = w.y;
42
+ into.w.z = w.z;
43
+ }
44
+ function copyPose(from, into) {
45
+ into.t.x = from.t.x;
46
+ into.t.y = from.t.y;
47
+ into.t.z = from.t.z;
48
+ into.r.x = from.r.x;
49
+ into.r.y = from.r.y;
50
+ into.r.z = from.r.z;
51
+ into.r.w = from.r.w;
52
+ into.v.x = from.v.x;
53
+ into.v.y = from.v.y;
54
+ into.v.z = from.v.z;
55
+ into.w.x = from.w.x;
56
+ into.w.y = from.w.y;
57
+ into.w.z = from.w.z;
58
+ }
59
+ function quatMul(a, b, out) {
60
+ out.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;
61
+ out.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;
62
+ out.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;
63
+ out.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;
64
+ }
65
+ function decayQuat(q, k) {
66
+ const s = 1 - k;
67
+ const sign = q.w < 0 ? -1 : 1;
68
+ let x = q.x * s * sign;
69
+ let y = q.y * s * sign;
70
+ let z = q.z * s * sign;
71
+ let w = q.w * s * sign + k;
72
+ const len = Math.hypot(x, y, z, w);
73
+ if (len > 1e-9) {
74
+ x /= len;
75
+ y /= len;
76
+ z /= len;
77
+ w /= len;
78
+ } else {
79
+ x = 0;
80
+ y = 0;
81
+ z = 0;
82
+ w = 1;
83
+ }
84
+ q.x = x;
85
+ q.y = y;
86
+ q.z = z;
87
+ q.w = w;
88
+ }
89
+ function lerpPose(a, b, alpha, out) {
90
+ const k = 1 - alpha;
91
+ out.t.x = a.t.x * k + b.t.x * alpha;
92
+ out.t.y = a.t.y * k + b.t.y * alpha;
93
+ out.t.z = a.t.z * k + b.t.z * alpha;
94
+ out.v.x = a.v.x * k + b.v.x * alpha;
95
+ out.v.y = a.v.y * k + b.v.y * alpha;
96
+ out.v.z = a.v.z * k + b.v.z * alpha;
97
+ out.w.x = a.w.x * k + b.w.x * alpha;
98
+ out.w.y = a.w.y * k + b.w.y * alpha;
99
+ out.w.z = a.w.z * k + b.w.z * alpha;
100
+ const dot = a.r.x * b.r.x + a.r.y * b.r.y + a.r.z * b.r.z + a.r.w * b.r.w;
101
+ const s = dot < 0 ? -alpha : alpha;
102
+ let x = a.r.x * k + b.r.x * s;
103
+ let y = a.r.y * k + b.r.y * s;
104
+ let z = a.r.z * k + b.r.z * s;
105
+ let w = a.r.w * k + b.r.w * s;
106
+ const len = Math.hypot(x, y, z, w);
107
+ if (len > 1e-9) {
108
+ x /= len;
109
+ y /= len;
110
+ z /= len;
111
+ w /= len;
112
+ }
113
+ out.r.x = x;
114
+ out.r.y = y;
115
+ out.r.z = z;
116
+ out.r.w = w;
117
+ }
118
+ function slotOf(tick) {
119
+ return (tick % POSE_RING + POSE_RING) % POSE_RING;
120
+ }
121
+ function poseAt(entry, tick) {
122
+ const slot = slotOf(tick);
123
+ return entry.poseTicks[slot] === tick ? entry.poses[slot] : void 0;
124
+ }
125
+ function key(collection, id) {
126
+ return `${collection}\0${id}`;
127
+ }
128
+ var engine;
129
+ var loading;
130
+ async function loadEngine() {
131
+ if (engine) return engine;
132
+ loading ??= (async () => {
133
+ const mod = await import("@dimforge/rapier3d-compat");
134
+ const ns = mod.default ?? mod;
135
+ await ns.init();
136
+ engine = ns;
137
+ return ns;
138
+ })();
139
+ return loading;
140
+ }
141
+ function planarLockWarning(spec) {
142
+ const boxed = (spec.colliders ?? []).some(
143
+ (c) => c.shape.type === CUBOID_SHAPE || c.shape.type === ROUND_CUBOID_SHAPE
144
+ );
145
+ if (!boxed) return void 0;
146
+ const b = spec.body;
147
+ const t = [b.translationsEnabledX, b.translationsEnabledY, b.translationsEnabledZ];
148
+ const r = [b.rotationsEnabledX, b.rotationsEnabledY, b.rotationsEnabledZ];
149
+ const axes = ["x", "y", "z"];
150
+ for (let i = 0; i < 3; i++) {
151
+ if (t[i]) continue;
152
+ if (r[(i + 1) % 3] || r[(i + 2) % 3]) continue;
153
+ const free = axes[i] === "z" ? "enabledRotations(true, false, true)" : "one of the other two rotations";
154
+ return `locks translation on ${axes[i]} and both rotations across that plane on a box-shaped body. That removes friction entirely: the box slides at a constant speed until it hits something. Free one out-of-plane rotation \u2014 in the xy plane that is ${free}, and it costs nothing, because geometry symmetric about the plane generates no torque about it.`;
155
+ }
156
+ return void 0;
157
+ }
158
+ var CUBOID_SHAPE = 1;
159
+ var ROUND_CUBOID_SHAPE = 12;
160
+ var PhysicsPredictor = class _PhysicsPredictor {
161
+ constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
162
+ this.store = store;
163
+ this.options = options;
164
+ this.meOf = meOf;
165
+ this.rttOf = rttOf;
166
+ this.tickIntervalOf = tickIntervalOf;
167
+ this.log = log;
168
+ this.collections = ext.collections.filter(
169
+ (c) => c.kind === "entity" && c.physics !== void 0
170
+ );
171
+ }
172
+ store;
173
+ options;
174
+ meOf;
175
+ rttOf;
176
+ tickIntervalOf;
177
+ log;
178
+ stats = {
179
+ freeSteps: 0,
180
+ resimSteps: 0,
181
+ rebases: 0,
182
+ snaps: 0,
183
+ suppressed: 0,
184
+ overCap: 0,
185
+ lastResimMicros: 0,
186
+ smoothing: 0,
187
+ stampGap: 0
188
+ };
189
+ rapier;
190
+ world;
191
+ bodies = /* @__PURE__ */ new Map();
192
+ /** Physics-backed entity collections, in schema order. */
193
+ collections;
194
+ warned = /* @__PURE__ */ new Set();
195
+ /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
196
+ overCapHigh = /* @__PURE__ */ new Map();
197
+ /** Authority arrived since the last frame: rebase before free-running. */
198
+ authorityDirty = false;
199
+ accumulatorMs = 0;
200
+ lastNow;
201
+ lastFrameNow;
202
+ failed = false;
203
+ /**
204
+ * The local world's head, on the server's tick timeline: the authoritative tick the last
205
+ * rebase started from plus the lead it re-stepped, then one per free-run step.
206
+ */
207
+ headTick = 0;
208
+ /** The newest server tick any authority has been seen for. `headTick`'s base. */
209
+ authorityTick = 0;
210
+ /** The newest tick a pose has been stored for. `-1` until the first capture. */
211
+ curTick = -1;
212
+ /**
213
+ * Where the renderer is, as a fractional tick. Advanced by wall time every frame and steered
214
+ * to sit about a tick behind `curTick`; `read()` draws between the stored poses either side
215
+ * of it.
216
+ */
217
+ renderTick;
218
+ /** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
219
+ scratch = emptyPose();
220
+ /** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
221
+ qa = { x: 0, y: 0, z: 0, w: 1 };
222
+ qb = { x: 0, y: 0, z: 0, w: 1 };
223
+ qc = { x: 0, y: 0, z: 0, w: 1 };
224
+ /**
225
+ * Per owned body: the server tick its authoritative record is *from* (the tick of the last
226
+ * `CORRECT` that touched it), and the per-tick prediction history — after every local step,
227
+ * the body's channels are recorded under the tick that step predicted. A correction for tick T
228
+ * is then judged against the client's prediction **for tick T**, not against the current head
229
+ * of the simulation (which legitimately leads authority by the whole latency — comparing
230
+ * against it would report the lead as misprediction).
231
+ */
232
+ authorityTicks = /* @__PURE__ */ new Map();
233
+ /** Smoothed stamp-minus-applied gap; see `noteWriteApplied`. */
234
+ stampGap = 0;
235
+ gapMeasured = false;
236
+ /** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
237
+ gapSampledThrough = 0;
238
+ predictedTicks = /* @__PURE__ */ new Map();
239
+ history = /* @__PURE__ */ new Map();
240
+ /** History kept per body — comfortably past the resim depth. */
241
+ static HISTORY_TICKS = 32;
242
+ get epsilon() {
243
+ return this.options.epsilon ?? PREDICTION_EPSILON;
244
+ }
245
+ /** `true` once the engine is loaded and the local world exists. */
246
+ get ready() {
247
+ return this.world !== void 0;
248
+ }
249
+ /**
250
+ * Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
251
+ * interpolation/authority — joining is never blocked on 2.9 MB of WASM.
252
+ */
253
+ start() {
254
+ if (this.world || this.failed) return Promise.resolve();
255
+ return loadEngine().then(
256
+ (rapier) => {
257
+ this.rapier = rapier;
258
+ const world = new rapier.World({ ...this.options.gravity });
259
+ const tickMs = this.tickIntervalOf();
260
+ world.timestep = this.options.timestep ?? (tickMs > 0 ? tickMs / 1e3 : 1 / 30);
261
+ if (this.options.setup) this.options.setup(world, rapier);
262
+ this.world = world;
263
+ this.authorityDirty = true;
264
+ },
265
+ (err) => {
266
+ this.failed = true;
267
+ this.log(
268
+ `irtio: physics prediction disabled \u2014 @dimforge/rapier3d-compat failed to load: ${err instanceof Error ? err.message : String(err)}`
269
+ );
270
+ }
271
+ );
272
+ }
273
+ free() {
274
+ this.bodies.clear();
275
+ this.world?.free();
276
+ this.world = void 0;
277
+ }
278
+ /** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
279
+ reset() {
280
+ this.authorityDirty = true;
281
+ this.accumulatorMs = 0;
282
+ this.lastNow = void 0;
283
+ this.renderTick = void 0;
284
+ this.curTick = -1;
285
+ for (const entry of this.bodies.values()) {
286
+ entry.poseTicks.fill(-1);
287
+ entry.preValid = false;
288
+ this.clearOffset(entry);
289
+ }
290
+ this.stampGap = 0;
291
+ this.gapMeasured = false;
292
+ this.gapSampledThrough = 0;
293
+ this.stats.stampGap = 0;
294
+ }
295
+ /**
296
+ * Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one).
297
+ *
298
+ * `tick` is the server tick it carried, and it is the base of the predictor's own head clock —
299
+ * which the renderer's interpolation is paced against. It has to be the *world's* clock rather
300
+ * than any one body's: a client with nothing of its own to own still draws a world full of
301
+ * predicted bodies.
302
+ */
303
+ noteAuthority(tick) {
304
+ this.authorityDirty = true;
305
+ if (tick !== void 0 && tick > this.authorityTick) this.authorityTick = tick;
306
+ }
307
+ /** The session tells us which server tick one body's authoritative record is from. */
308
+ noteAuthorityTick(collection, id, tick) {
309
+ this.authorityTicks.set(key(collection, id), tick);
310
+ }
311
+ /**
312
+ * A `CORRECT` reported both halves of a judged write: the stamp the client put on it and the
313
+ * server tick it was actually applied at (bug 1). The gap between them is the systematic error
314
+ * in the rebase's replay, and it is not zero even on a loopback: the stamp is the predictor's
315
+ * *head* (authority + lead), while the server applies on arrival, at a tick barely past the
316
+ * authority the client is replaying from. Replaying at the stamp therefore held the previous
317
+ * intent for `gap` ticks the server had already simulated under the new one — the overshoot on
318
+ * every key release, sized `gap x speed`.
319
+ *
320
+ * Smoothed, because it rides on delivery jitter and the measurement is one sample per judged
321
+ * write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
322
+ * authority is on the session's bare write counter rather than the server's tick stream, and
323
+ * differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
324
+ */
325
+ noteWriteApplied(stampTick, appliedTick) {
326
+ if (stampTick <= 0 || appliedTick <= 0 || stampTick <= this.gapSampledThrough) return;
327
+ this.gapSampledThrough = stampTick;
328
+ const sample = Math.min(RESIM_DEPTH, Math.max(0, stampTick - appliedTick));
329
+ this.stampGap = this.gapMeasured ? this.stampGap + (sample - this.stampGap) * GAP_ALPHA : sample;
330
+ this.gapMeasured = true;
331
+ this.stats.stampGap = this.stampGap;
332
+ }
333
+ /** Does the local world currently simulate `collection[id]`? */
334
+ has(collection, id) {
335
+ return this.bodies.has(key(collection, id));
336
+ }
337
+ /**
338
+ * Is a correction's every value within the suppression tolerance of the prediction it judges?
339
+ * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
340
+ * channels against `epsilon / timestep`, because a velocity disagreement matters by what it
341
+ * moves in one tick.
342
+ */
343
+ withinEpsilon(desc, fields, patch, predicted) {
344
+ const physics = desc.physics;
345
+ if (!physics || fields.length === 0) return false;
346
+ const eps = this.epsilon;
347
+ const dt = this.world?.timestep ?? 1 / 30;
348
+ const channelOf = new Map(physics.channels.map(([c, f]) => [f, c]));
349
+ return fields.every((f) => {
350
+ const a = predicted[f];
351
+ const b = patch[f];
352
+ if (typeof a !== "number" || typeof b !== "number") return false;
353
+ const channel = channelOf.get(f);
354
+ const velocity = channel?.startsWith("v") === true || channel?.startsWith("w") === true;
355
+ const tolerance = velocity ? eps / dt : eps;
356
+ return Math.abs(a - b) <= tolerance;
357
+ });
358
+ }
359
+ /** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
360
+ predictsCollection(name) {
361
+ return this.collections.some((c) => c.name === name);
362
+ }
363
+ /**
364
+ * Advances the local world to `now`. Driven by render reads (one pass per timestamp), so a
365
+ * draw loop — or a bot's render sampling — is the clock; there is no timer.
366
+ */
367
+ frame(now) {
368
+ if (!this.world || now === this.lastFrameNow && !this.authorityDirty) return;
369
+ const elapsedMs = this.lastFrameNow === void 0 ? 0 : Math.max(0, now - this.lastFrameNow);
370
+ this.advanceRenderClock(now);
371
+ this.lastFrameNow = now;
372
+ this.reconcileBodies();
373
+ this.decayOffsets(elapsedMs);
374
+ this.snapshotDrawn();
375
+ if (this.authorityDirty) {
376
+ this.authorityDirty = false;
377
+ this.rebase();
378
+ this.accumulatorMs = -FREE_RUN_GRACE * this.timestepMs();
379
+ this.lastNow = now;
380
+ if (this.curTick < this.headTick) this.capturePoses();
381
+ } else {
382
+ this.freeRun(now);
383
+ }
384
+ this.holdRenderTick();
385
+ this.absorbJump();
386
+ }
387
+ // -------------------------------------------------------------------------
388
+ // The render clock
389
+ // -------------------------------------------------------------------------
390
+ /**
391
+ * The head moved: store every predicted body's pose under the tick it now holds.
392
+ *
393
+ * A tick can be simulated more than once — a `DELTA` and a `CORRECT` both arrive for the same
394
+ * tick and each rebases — so the slot is simply rewritten with the better answer. It can also
395
+ * move backwards, when a free-run step is discarded by the rebase behind it; `curTick` does
396
+ * not follow it down, because moving the newest label backwards would drag the segment the
397
+ * renderer is crossing backwards with it.
398
+ */
399
+ capturePoses() {
400
+ const tick = this.headTick;
401
+ const slot = slotOf(tick);
402
+ for (const entry of this.bodies.values()) {
403
+ readPose(entry.body, entry.poses[slot]);
404
+ entry.poseTicks[slot] = tick;
405
+ }
406
+ if (tick > this.curTick) this.curTick = tick;
407
+ }
408
+ /**
409
+ * Advance the render clock by one frame of wall time, steering it towards a target distance
410
+ * behind the head rather than clamping it there.
411
+ *
412
+ * `renderTick` and `curTick` agree on rate — one tick per tick period — but not on phase, and
413
+ * the phase error is delivery jitter: authority arrives when the network hands it over, not
414
+ * every 33 ms however steady the server is. Left alone the two would wander apart, so the
415
+ * clock is steered, by rate rather than by position. What the network did to a packet is not
416
+ * something the player's character should be seen doing.
417
+ */
418
+ advanceRenderClock(now) {
419
+ if (this.renderTick === void 0 || this.lastFrameNow === void 0) return;
420
+ const period = this.timestepMs();
421
+ if (period <= 0) return;
422
+ const lag = this.curTick - this.renderTick;
423
+ const rate = lag > LAG_TARGET_MAX ? 1 + CLOCK_TRIM : lag < LAG_TARGET_MIN ? 1 - CLOCK_TRIM : 1;
424
+ this.renderTick += Math.max(0, now - this.lastFrameNow) / period * rate;
425
+ }
426
+ /**
427
+ * Keep the render clock inside the span there are poses for.
428
+ *
429
+ * Both ends are real. At `curTick` there is nothing further to draw towards, so a late arrival
430
+ * holds the pose rather than extrapolating motion the next tick would have to take back — the
431
+ * same trade that sized the prediction lead (bug 1). At the far end the ring has recycled the
432
+ * slot and the pose is genuinely gone. Between them the clock floats, and the rate trim above
433
+ * is what keeps it from spending its time against either stop.
434
+ */
435
+ holdRenderTick() {
436
+ if (this.renderTick === void 0) {
437
+ this.renderTick = this.curTick;
438
+ return;
439
+ }
440
+ const oldest = this.curTick - (POSE_RING - 1);
441
+ if (this.renderTick > this.curTick) this.renderTick = this.curTick;
442
+ else if (this.renderTick < oldest) this.renderTick = oldest;
443
+ }
444
+ /** `smoothingHalfLifeMs`, or 0 when the game turned the smoothing off. */
445
+ halfLifeMs() {
446
+ return Math.max(0, this.options.smoothingHalfLifeMs ?? SMOOTHING_HALF_LIFE_MS);
447
+ }
448
+ /** Remember where every body is being drawn, before this frame re-simulates anything. */
449
+ snapshotDrawn() {
450
+ if (this.halfLifeMs() === 0) return;
451
+ const from = this.renderTick === void 0 ? void 0 : Math.floor(this.renderTick);
452
+ for (const entry of this.bodies.values()) {
453
+ entry.preValid = from !== void 0 && poseAt(entry, from) !== void 0;
454
+ if (entry.preValid) copyPose(this.rawPose(entry), entry.pre);
455
+ }
456
+ }
457
+ /**
458
+ * Take whatever the re-simulation moved the drawn pose by and put it in the offset instead.
459
+ *
460
+ * This is the whole of correction smoothing, and it is smoothing the *error*: the difference
461
+ * measured here is between two answers to the same question — where is this body at the render
462
+ * clock — asked either side of a rebase. Motion the simulation produced is not in it, because
463
+ * the render clock advanced before the snapshot. So real movement is never slowed, which is
464
+ * the thing a rate limiter on the drawn position cannot promise: it sees a position that moved
465
+ * and has no way to know whether the body ran or was corrected.
466
+ *
467
+ * It absorbs more than server disagreement. A newly flushed intent changes what the last few
468
+ * ticks should have been, and the replay hands that over as a jump the same way; so does the
469
+ * render clock being pulled back inside the poses it has. All of it is the same defect from
470
+ * the player's side — the character was drawn somewhere it now turns out it was not — and all
471
+ * of it eases away here.
472
+ */
473
+ absorbJump() {
474
+ const halfLife = this.halfLifeMs();
475
+ if (halfLife === 0) return;
476
+ const snap = Math.max(0, this.options.smoothingSnapUnits ?? SMOOTHING_SNAP_UNITS);
477
+ const eps = this.epsilon;
478
+ let worst = 0;
479
+ for (const entry of this.bodies.values()) {
480
+ if (!entry.preValid) continue;
481
+ entry.preValid = false;
482
+ const after = this.rawPose(entry);
483
+ const dx = entry.pre.t.x - after.t.x;
484
+ const dy = entry.pre.t.y - after.t.y;
485
+ const dz = entry.pre.t.z - after.t.z;
486
+ if (Math.abs(dx) + Math.abs(dy) + Math.abs(dz) >= eps) {
487
+ entry.offset.x += dx;
488
+ entry.offset.y += dy;
489
+ entry.offset.z += dz;
490
+ }
491
+ if (entry.pre.r.x !== after.r.x || entry.pre.r.y !== after.r.y || entry.pre.r.z !== after.r.z || entry.pre.r.w !== after.r.w) {
492
+ this.qa.x = -after.r.x;
493
+ this.qa.y = -after.r.y;
494
+ this.qa.z = -after.r.z;
495
+ this.qa.w = after.r.w;
496
+ quatMul(entry.pre.r, this.qa, this.qb);
497
+ quatMul(entry.rotOffset, this.qb, this.qc);
498
+ entry.rotOffset.x = this.qc.x;
499
+ entry.rotOffset.y = this.qc.y;
500
+ entry.rotOffset.z = this.qc.z;
501
+ entry.rotOffset.w = this.qc.w;
502
+ }
503
+ const size = Math.hypot(entry.offset.x, entry.offset.y, entry.offset.z);
504
+ if (size > snap) this.clearOffset(entry);
505
+ else if (size > worst) worst = size;
506
+ }
507
+ this.stats.smoothing = worst;
508
+ }
509
+ /**
510
+ * Ease every offset towards zero. Exponential, so the rate is proportional to the error and
511
+ * there is no world-scale speed to pick: a correction worth a tenth of a unit is worked off
512
+ * gently and one worth a whole unit is not left hanging around for a second.
513
+ */
514
+ decayOffsets(elapsedMs) {
515
+ const halfLife = this.halfLifeMs();
516
+ if (halfLife === 0 || elapsedMs <= 0) return;
517
+ const k = 1 - 2 ** (-elapsedMs / halfLife);
518
+ for (const entry of this.bodies.values()) {
519
+ const o = entry.offset;
520
+ if (o.x !== 0 || o.y !== 0 || o.z !== 0) {
521
+ o.x -= o.x * k;
522
+ o.y -= o.y * k;
523
+ o.z -= o.z * k;
524
+ if (Math.abs(o.x) < 1e-6) o.x = 0;
525
+ if (Math.abs(o.y) < 1e-6) o.y = 0;
526
+ if (Math.abs(o.z) < 1e-6) o.z = 0;
527
+ }
528
+ if (entry.rotOffset.w < 1 - 1e-9) decayQuat(entry.rotOffset, k);
529
+ }
530
+ }
531
+ clearOffset(entry) {
532
+ entry.offset.x = 0;
533
+ entry.offset.y = 0;
534
+ entry.offset.z = 0;
535
+ entry.rotOffset.x = 0;
536
+ entry.rotOffset.y = 0;
537
+ entry.rotOffset.z = 0;
538
+ entry.rotOffset.w = 1;
539
+ }
540
+ /**
541
+ * The pose to draw one body at: the stored poses either side of the render clock, blended.
542
+ * Falls back to the body's live transform when the ring cannot bracket it — before the first
543
+ * capture, and for a body that appeared this frame.
544
+ */
545
+ rawPose(entry) {
546
+ const rt = this.renderTick;
547
+ if (rt !== void 0) {
548
+ const from = Math.floor(rt);
549
+ const a = poseAt(entry, from);
550
+ const b = poseAt(entry, from + 1);
551
+ if (a && b) {
552
+ lerpPose(a, b, rt - from, this.scratch);
553
+ return this.scratch;
554
+ }
555
+ if (a) {
556
+ copyPose(a, this.scratch);
557
+ return this.scratch;
558
+ }
559
+ }
560
+ readPose(entry.body, this.scratch);
561
+ return this.scratch;
562
+ }
563
+ /** `rawPose` with the render-error offset applied — what the draw loop actually gets. */
564
+ renderPose(entry) {
565
+ const pose = this.rawPose(entry);
566
+ const o = entry.offset;
567
+ if (o.x !== 0 || o.y !== 0 || o.z !== 0) {
568
+ pose.t.x += o.x;
569
+ pose.t.y += o.y;
570
+ pose.t.z += o.z;
571
+ }
572
+ if (entry.rotOffset.w < 1 - 1e-9) {
573
+ quatMul(entry.rotOffset, pose.r, this.qa);
574
+ pose.r.x = this.qa.x;
575
+ pose.r.y = this.qa.y;
576
+ pose.r.z = this.qa.z;
577
+ pose.r.w = this.qa.w;
578
+ }
579
+ return pose;
580
+ }
581
+ /**
582
+ * The predicted record for `collection[id]`: the authoritative record (which already carries
583
+ * local intent writes — `track()` writes through to plain state) with the body-mapped channels
584
+ * replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
585
+ * reads is what the wire would carry.
586
+ *
587
+ * Drawn *between* two simulated ticks, not at the head. The local world steps at the room's
588
+ * tick rate, and only when authority arrives to rebase it; a display runs at two to four times
589
+ * that and asks on every frame. Handing back the head means the answer changes on some frames
590
+ * and not others, so a character running at a steady nine units a second is drawn standing
591
+ * still for half of them and moving at twenty for the rest. It is the player's own character
592
+ * answering their own key, and it reads as the game hitching.
593
+ *
594
+ * This is a *render* read. `room.state` and every correctness path — `predictedValues`, the
595
+ * per-tick history a correction is judged against — still see the head exactly as before.
596
+ */
597
+ read(desc, id, base) {
598
+ const entry = this.bodies.get(key(desc.name, id));
599
+ const physics = desc.physics;
600
+ if (!entry || !physics) return base;
601
+ const out = { ...base };
602
+ const pose = this.renderPose(entry);
603
+ for (const [channel, field] of physics.channels) {
604
+ const value = channelValue(channel, pose.t, pose.r, pose.v, pose.w);
605
+ out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
606
+ }
607
+ return out;
608
+ }
609
+ /**
610
+ * The local world's current values for `fields` of one body — what a correction is judged
611
+ * against (`previous` on the `correct` event, and the epsilon-suppression comparison).
612
+ */
613
+ predictedValues(desc, id, fields, atTick) {
614
+ const entry = this.bodies.get(key(desc.name, id));
615
+ const physics = desc.physics;
616
+ if (!entry || !physics) return void 0;
617
+ const remembered = atTick !== void 0 ? this.history.get(key(desc.name, id))?.get(atTick) : void 0;
618
+ const t = entry.body.translation();
619
+ const r = entry.body.rotation();
620
+ const v = entry.body.linvel();
621
+ const w = entry.body.angvel();
622
+ const out = {};
623
+ for (const [channel, field] of physics.channels) {
624
+ if (!fields.includes(field)) continue;
625
+ const value = remembered !== void 0 && typeof remembered[field] === "number" ? remembered[field] : channelValue(channel, t, r, v, w);
626
+ out[field] = this.isF32(desc, field) ? Math.fround(value) : value;
627
+ }
628
+ return out;
629
+ }
630
+ // -------------------------------------------------------------------------
631
+ // Membership
632
+ // -------------------------------------------------------------------------
633
+ /**
634
+ * Mirrors the local world's bodies onto the instances this client predicts: every physics
635
+ * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
636
+ * (collection order, then insertion order — the same stated iteration guarantee the server
637
+ * follows, so which bodies fall over the cap is deterministic).
638
+ *
639
+ * Everything else — non-`predicted` collections, and `predicted` instances over the cap — gets
640
+ * no body and no collider here. Those instances still render (the interpolation path reads
641
+ * authoritative state directly), but nothing in the local world can touch them.
642
+ */
643
+ reconcileBodies() {
644
+ const world = this.world;
645
+ const rapier = this.rapier;
646
+ if (!world || !rapier) return;
647
+ const me = this.meOf();
648
+ const live = /* @__PURE__ */ new Set();
649
+ let nonOwned = 0;
650
+ let overCap = 0;
651
+ const overCapBy = /* @__PURE__ */ new Map();
652
+ const cap = this.options.maxPredictedBodies ?? MAX_PREDICTED_BODIES;
653
+ for (const desc of this.collections) {
654
+ const coll = this.store.plainCollection(desc.name);
655
+ for (const id of coll.ids()) {
656
+ const owned = coll.ownerOf(id) === me && me !== "";
657
+ if (!owned) {
658
+ if (!desc.predicted) continue;
659
+ if (nonOwned >= cap) {
660
+ overCap++;
661
+ overCapBy.set(desc.name, (overCapBy.get(desc.name) ?? 0) + 1);
662
+ continue;
663
+ }
664
+ nonOwned++;
665
+ }
666
+ const k = key(desc.name, id);
667
+ live.add(k);
668
+ if (this.bodies.has(k)) continue;
669
+ const record = coll.get(id);
670
+ if (record) this.createBody(desc, id, record, owned);
671
+ }
672
+ }
673
+ this.stats.overCap = overCap;
674
+ this.warnOverCap(cap, overCapBy);
675
+ for (const [k, entry] of [...this.bodies]) {
676
+ if (live.has(k)) continue;
677
+ this.bodies.delete(k);
678
+ this.authorityTicks.delete(k);
679
+ this.predictedTicks.delete(k);
680
+ this.history.delete(k);
681
+ this.world?.removeRigidBody(entry.body);
682
+ }
683
+ }
684
+ createBody(desc, id, record, owned) {
685
+ const world = this.world;
686
+ const rapier = this.rapier;
687
+ if (!world || !rapier) return;
688
+ const factory = this.options.bodies?.[desc.name];
689
+ if (!factory) {
690
+ this.warnOnce(
691
+ `factory:${desc.name}`,
692
+ `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.`
693
+ );
694
+ return;
695
+ }
696
+ const physics = desc.physics;
697
+ if (physics && !physics.channels.some(([c]) => c.startsWith("v"))) {
698
+ this.warnOnce(
699
+ `velocity:${desc.name}`,
700
+ `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.`
701
+ );
702
+ }
703
+ const spec = factory(rapier, record, id);
704
+ if (!spec || !spec.body) return;
705
+ const planar = planarLockWarning(spec);
706
+ if (planar !== void 0) {
707
+ this.warnOnce(`planar:${desc.name}`, `irtio: physics.bodies.${desc.name} ${planar}`);
708
+ }
709
+ const body = world.createRigidBody(spec.body);
710
+ for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
711
+ const entry = {
712
+ desc,
713
+ id,
714
+ body,
715
+ owned,
716
+ poses: Array.from({ length: POSE_RING }, emptyPose),
717
+ poseTicks: new Array(POSE_RING).fill(-1),
718
+ offset: { x: 0, y: 0, z: 0 },
719
+ rotOffset: { x: 0, y: 0, z: 0, w: 1 },
720
+ pre: emptyPose(),
721
+ preValid: false
722
+ };
723
+ this.applyRecord(entry, record);
724
+ for (let i = 0; i < POSE_RING; i++) {
725
+ readPose(body, entry.poses[i]);
726
+ }
727
+ for (let back = 0; back < POSE_RING; back++) {
728
+ const tick = this.curTick - back;
729
+ if (tick < 0) break;
730
+ entry.poseTicks[slotOf(tick)] = tick;
731
+ }
732
+ this.bodies.set(key(desc.name, id), entry);
733
+ }
734
+ /**
735
+ * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
736
+ * world right now, so a predicted body walks through them. Warn on every new high-water mark
737
+ * per collection — a game that grows past the cap mid-session hears about it, and a count that
738
+ * oscillates around one level does not turn the console into a log.
739
+ */
740
+ warnOverCap(cap, overCapBy) {
741
+ for (const [name, count] of overCapBy) {
742
+ if (count <= (this.overCapHigh.get(name) ?? 0)) continue;
743
+ this.overCapHigh.set(name, count);
744
+ this.log(
745
+ `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.`
746
+ );
747
+ }
748
+ }
749
+ warnOnce(k, message) {
750
+ if (this.warned.has(k)) return;
751
+ this.warned.add(k);
752
+ this.log(message);
753
+ }
754
+ // -------------------------------------------------------------------------
755
+ // Rebase and free-run
756
+ // -------------------------------------------------------------------------
757
+ /**
758
+ * The client's lead over authority, in ticks: the one-way transit the authority in hand has
759
+ * already spent in flight, rounded to the nearest tick. Nothing more.
760
+ *
761
+ * Every tick of lead beyond that transit is a tick of motion the client draws on the
762
+ * assumption that the input it is holding will still be held when the server gets there. On a
763
+ * release that assumption is wrong by construction, and the invented travel is handed back as
764
+ * a backwards yank — bug 1's "keeps moving after key up and then snaps back". It was
765
+ * `ceil(owd) + 1`, which on a fast link is two whole ticks of margin over a transit of nearly
766
+ * zero. Measured on the arena fixture at no injected latency (release overshoot of a 6 u/s
767
+ * held-input character, `physics-predict.test.ts`):
768
+ *
769
+ * | lead | drawn overshoot past the server's stop | worst backwards step |
770
+ * | --- | --- | --- |
771
+ * | `ceil(owd) + 1` (was) | 0.585 u | -0.585 u |
772
+ * | `max(1, round(owd))` (is) | 0.293 u | -0.292 u |
773
+ * | `round(owd)`, floor 0 | 0.0008 u | -0.0008 u |
774
+ *
775
+ * One tick of running is 0.3 u there: the overshoot is the lead, in ticks, and nothing else.
776
+ * On a link with no transit to cover, every tick of lead is a tick of guessing that the key is
777
+ * still held.
778
+ *
779
+ * The floor of 1 is not margin, it is the smallest lead that is still prediction. At 0 the
780
+ * rebase takes no steps: the local world is pinned to the authority it just received, which is
781
+ * already a tick old, so the client responds to its own input only when the next `CORRECT`
782
+ * arrives and every moving body reads as mispredicted by one tick of motion. Measured: the
783
+ * bot suites' correction-storm invariant fires immediately (21 corrections/s per bot against a
784
+ * threshold of 5 in `packages/cli/test/simulate-physics.test.ts`). One tick of speculation is
785
+ * the price of predicting at all; two was the bug.
786
+ *
787
+ * Erring low is also the cheap direction — a lead shorter than the transit means authority
788
+ * arrives slightly *ahead* of the prediction, and a correction that pulls the character the way
789
+ * it is already going is the one nobody can see.
790
+ *
791
+ * Measured earlier the same day (dive e2e): a *full*-round-trip lead was worse again
792
+ * (-1.4 to -1.8 u worst backwards step vs -0.6 at half), which is the same finding from the
793
+ * other end. `stats.stampGap` is the running check on all of this — it reports how far ahead
794
+ * of the server's application the stamps still land, and it should sit at 0.
795
+ */
796
+ leadTicks() {
797
+ return Math.max(1, Math.round(this.rttOf() / 2 / this.timestepMs()));
798
+ }
799
+ timestepMs() {
800
+ return (this.world?.timestep ?? 1 / 30) * 1e3;
801
+ }
802
+ /**
803
+ * Rebase + re-step: snap every predicted body to the authoritative record (server values —
804
+ * `DELTA` for non-owned bodies, `CORRECT` for owned ones; body fields are never client-
805
+ * written, so plain state holds exactly what the server said), then re-step the world by the
806
+ * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
807
+ * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
808
+ * judged write) before the first of them. Bounded by the shared resim depth: an outrun lead
809
+ * snaps to authority and counts (`stats.snaps`).
810
+ */
811
+ rebase() {
812
+ const world = this.world;
813
+ const rapier = this.rapier;
814
+ if (!world || !rapier) return;
815
+ this.stats.rebases++;
816
+ for (const entry of this.bodies.values()) {
817
+ const record = this.store.plainCollection(entry.desc.name).get(entry.id);
818
+ if (record) this.applyRecord(entry, record);
819
+ }
820
+ for (const entry of this.bodies.values()) {
821
+ if (!entry.owned) continue;
822
+ const k = key(entry.desc.name, entry.id);
823
+ this.predictedTicks.set(k, this.authorityTicks.get(k) ?? this.predictedTicks.get(k) ?? 0);
824
+ }
825
+ const lead = this.leadTicks();
826
+ this.headTick = this.authorityTick;
827
+ if (lead > RESIM_DEPTH) {
828
+ this.stats.snaps++;
829
+ return;
830
+ }
831
+ const replays = /* @__PURE__ */ new Map();
832
+ for (const entry of this.bodies.values()) {
833
+ if (!entry.owned) continue;
834
+ const k = key(entry.desc.name, entry.id);
835
+ replays.set(k, {
836
+ frames: this.store.pendingWritePatches(entry.desc.name, entry.id),
837
+ next: 0,
838
+ inForce: this.store.baselineIntent(entry.desc.name, entry.id)
839
+ });
840
+ }
841
+ const started = performance.now();
842
+ for (let step = 0; step < lead; step++) {
843
+ this.applyIntents(rapier, world, (k) => {
844
+ const r = replays.get(k);
845
+ if (!r) return void 0;
846
+ const t = (this.predictedTicks.get(k) ?? 0) + 1;
847
+ const gap = Math.round(this.stampGap);
848
+ while (r.next < r.frames.length && r.frames[r.next].tick - gap <= t) {
849
+ const f = r.frames[r.next];
850
+ r.inForce = r.inForce ? { ...r.inForce, ...f.patch } : { ...f.patch };
851
+ r.next++;
852
+ }
853
+ return r.inForce;
854
+ });
855
+ world.step();
856
+ this.recordStep();
857
+ this.headTick++;
858
+ if (lead - step <= POSE_RING) this.capturePoses();
859
+ this.stats.resimSteps++;
860
+ }
861
+ this.stats.lastResimMicros = Math.round((performance.now() - started) * 1e3);
862
+ }
863
+ /**
864
+ * The tick a `WRITE` flushed right now should be stamped with: one past the newest predicted
865
+ * head across owned bodies — the first tick the new intent can affect. That puts the stamp on
866
+ * the same clock as `authorityTicks` and the resim window (the server's tick stream), which is
867
+ * what lets the rebase walk pending writes by tick instead of by array index. `undefined`
868
+ * until the engine runs or while nothing is owned; the session falls back to its counter.
869
+ */
870
+ stampTick() {
871
+ if (!this.world) return void 0;
872
+ let head;
873
+ for (const entry of this.bodies.values()) {
874
+ if (!entry.owned) continue;
875
+ const t = this.predictedTicks.get(key(entry.desc.name, entry.id));
876
+ if (t !== void 0 && (head === void 0 || t > head)) head = t;
877
+ }
878
+ return head === void 0 ? void 0 : head + 1;
879
+ }
880
+ /** After every step: advance each owned body's prediction clock and remember its channels. */
881
+ recordStep() {
882
+ for (const entry of this.bodies.values()) {
883
+ if (!entry.owned) continue;
884
+ const physics = entry.desc.physics;
885
+ if (!physics) continue;
886
+ const k = key(entry.desc.name, entry.id);
887
+ const tick = (this.predictedTicks.get(k) ?? 0) + 1;
888
+ this.predictedTicks.set(k, tick);
889
+ const t = entry.body.translation();
890
+ const r = entry.body.rotation();
891
+ const v = entry.body.linvel();
892
+ const w = entry.body.angvel();
893
+ const record = {};
894
+ for (const [channel, field] of physics.channels) {
895
+ record[field] = channelValue(channel, t, r, v, w);
896
+ }
897
+ let byTick = this.history.get(k);
898
+ if (!byTick) {
899
+ byTick = /* @__PURE__ */ new Map();
900
+ this.history.set(k, byTick);
901
+ }
902
+ byTick.set(tick, record);
903
+ byTick.delete(tick - _PhysicsPredictor.HISTORY_TICKS);
904
+ }
905
+ }
906
+ /** Returns the number of steps taken, so the caller knows whether the head moved. */
907
+ freeRun(now) {
908
+ const world = this.world;
909
+ const rapier = this.rapier;
910
+ if (!world || !rapier) return 0;
911
+ if (this.lastNow === void 0) {
912
+ this.lastNow = now;
913
+ return 0;
914
+ }
915
+ this.accumulatorMs += Math.max(0, now - this.lastNow);
916
+ this.lastNow = now;
917
+ const dtMs = this.timestepMs();
918
+ if (this.accumulatorMs > dtMs * MAX_FREE_STEPS_PER_FRAME) {
919
+ this.accumulatorMs = dtMs * MAX_FREE_STEPS_PER_FRAME;
920
+ }
921
+ let steps = 0;
922
+ while (this.accumulatorMs >= dtMs) {
923
+ this.accumulatorMs -= dtMs;
924
+ this.applyIntents(rapier, world, () => void 0);
925
+ world.step();
926
+ this.recordStep();
927
+ this.headTick++;
928
+ this.capturePoses();
929
+ this.stats.freeSteps++;
930
+ steps++;
931
+ }
932
+ return steps;
933
+ }
934
+ /**
935
+ * Applies intents for every owned predicted body before one step: the intent in force at the
936
+ * tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
937
+ * current values (free-run: plain state carries the newest local intent writes, which is
938
+ * correct there because free-run steps are the ticks *after* every buffered write).
939
+ */
940
+ applyIntents(rapier, world, frameFor) {
941
+ for (const entry of this.bodies.values()) {
942
+ if (!entry.owned) continue;
943
+ const hook = this.options.intents?.[entry.desc.name];
944
+ if (!hook) continue;
945
+ const record = this.store.plainCollection(entry.desc.name).get(entry.id);
946
+ if (!record) continue;
947
+ const frame = frameFor(key(entry.desc.name, entry.id));
948
+ hook(entry.body, frame ? { ...record, ...frame } : record, rapier, world);
949
+ }
950
+ }
951
+ /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
952
+ applyRecord(entry, record) {
953
+ const physics = entry.desc.physics;
954
+ if (!physics) return;
955
+ const body = entry.body;
956
+ const t = { ...body.translation() };
957
+ const r = { ...body.rotation() };
958
+ const v = { ...body.linvel() };
959
+ const w = { ...body.angvel() };
960
+ let setT = false;
961
+ let setR = false;
962
+ let setV = false;
963
+ let setW = false;
964
+ for (const [channel, field] of physics.channels) {
965
+ const raw = record[field];
966
+ if (typeof raw !== "number") continue;
967
+ switch (channel) {
968
+ case "x":
969
+ case "y":
970
+ case "z":
971
+ t[channel] = raw;
972
+ setT = true;
973
+ break;
974
+ case "qx":
975
+ r.x = raw;
976
+ setR = true;
977
+ break;
978
+ case "qy":
979
+ r.y = raw;
980
+ setR = true;
981
+ break;
982
+ case "qz":
983
+ r.z = raw;
984
+ setR = true;
985
+ break;
986
+ case "qw":
987
+ r.w = raw;
988
+ setR = true;
989
+ break;
990
+ case "vx":
991
+ v.x = raw;
992
+ setV = true;
993
+ break;
994
+ case "vy":
995
+ v.y = raw;
996
+ setV = true;
997
+ break;
998
+ case "vz":
999
+ v.z = raw;
1000
+ setV = true;
1001
+ break;
1002
+ case "wx":
1003
+ w.x = raw;
1004
+ setW = true;
1005
+ break;
1006
+ case "wy":
1007
+ w.y = raw;
1008
+ setW = true;
1009
+ break;
1010
+ case "wz":
1011
+ w.z = raw;
1012
+ setW = true;
1013
+ break;
1014
+ }
1015
+ }
1016
+ if (setT) body.setTranslation(t, true);
1017
+ if (setR) body.setRotation(r, true);
1018
+ if (setV) body.setLinvel(v, true);
1019
+ if (setW) body.setAngvel(w, true);
1020
+ }
1021
+ isF32(desc, field) {
1022
+ const idx = desc.fieldIndex.get(field);
1023
+ if (idx === void 0) return false;
1024
+ return desc.fields[idx]?.type.kind === "f32";
1025
+ }
1026
+ };
1027
+ function channelValue(channel, t, r, v, w) {
1028
+ switch (channel) {
1029
+ case "x":
1030
+ return t.x;
1031
+ case "y":
1032
+ return t.y;
1033
+ case "z":
1034
+ return t.z;
1035
+ case "qx":
1036
+ return r.x;
1037
+ case "qy":
1038
+ return r.y;
1039
+ case "qz":
1040
+ return r.z;
1041
+ case "qw":
1042
+ return r.w;
1043
+ case "vx":
1044
+ return v.x;
1045
+ case "vy":
1046
+ return v.y;
1047
+ case "vz":
1048
+ return v.z;
1049
+ case "wx":
1050
+ return w.x;
1051
+ case "wy":
1052
+ return w.y;
1053
+ default:
1054
+ return w.z;
1055
+ }
1056
+ }
1057
+ export {
1058
+ PhysicsPredictor
1059
+ };