@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.d.ts CHANGED
@@ -1,15 +1,644 @@
1
- import { ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy, AnySchema, PlainState, CollectionDesc, Delta } from '@irtio/schema';
1
+ import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
2
+ import RAPIER from '@dimforge/rapier3d-compat';
2
3
  import { PresenceRecord } from '@irtio/protocol';
3
4
 
4
5
  /**
5
- * `@irtio/client`'s public contract: the `Room` surface `joinRoom` returns, its options, the
6
- * client-side state types, and the two injectable seams (`Transport`, `Scheduler`).
6
+ * The client's state layer.
7
+ *
8
+ * One plain `PlainState` (decoded from the join snapshot, advanced by every `DELTA`) with two
9
+ * views over it:
7
10
  *
8
- * Nothing here imports a runtime dependency: the package speaks the wire with `@irtio/protocol`
9
- * and `@irtio/schema` only, and reaches for the global `WebSocket` that Node 22 and every
10
- * browser already have.
11
+ * - **owned instances** the `track()` proxy for that record, so a write normalizes exactly like
12
+ * the server would (f32 quantized, ranges and string sizes enforced by a throw) and marks a
13
+ * dirty set the flush window turns into one `WRITE`;
14
+ * - **everything else** → `frozenProxy`, which ignores writes and warns once with the ownership
15
+ * hint.
16
+ *
17
+ * Which one you get is decided per `get()`, so an ownership change needs no bookkeeping — but the
18
+ * object identity is not stable across it, which is why `state.x.get(id)` must be re-read after a
19
+ * grab (documented in the README).
20
+ *
21
+ * The server-wins rule lives here too: a `DELTA`'s update ops are dropped field-wise for
22
+ * instances this client owns (adds, removes and owner changes still apply), while a `CORRECT`
23
+ * applies in full and clears the matching local dirty marks.
11
24
  */
12
25
 
26
+ type AnyRecord$3 = Record<string, unknown>;
27
+ /**
28
+ * Resimulation depth cap (D19): how many flushed-but-unjudged `WRITE`s the client retains for
29
+ * replay after a `CORRECT`. A correction older than the retained window snaps everything it
30
+ * corrects and counts it (`snapped`); buffer memory is bounded by the same window.
31
+ */
32
+ declare const RESIM_DEPTH = 20;
33
+ /** One correction op, as the `'correct'` event sees it. */
34
+ interface CorrectionOp {
35
+ readonly collection: string;
36
+ readonly id: string;
37
+ readonly fields: readonly string[];
38
+ readonly patch: AnyRecord$3;
39
+ /** The local (predicted) values of `fields` right before the snap — what `patch` overrode. */
40
+ readonly previous: AnyRecord$3;
41
+ readonly tick: number;
42
+ /**
43
+ * The client write tick the server judged (D19), absent against a pre-week-8 server. Local
44
+ * writes newer than it were re-applied on top of the correction.
45
+ */
46
+ readonly clientTick?: number;
47
+ /** How many pending local writes were re-applied over this correction's values. */
48
+ readonly replayed: number;
49
+ /** `true` when the correction outran the resim window and everything it named snapped. */
50
+ readonly snapped: boolean;
51
+ }
52
+ declare class ClientStore {
53
+ readonly ext: AnySchema;
54
+ /** Reads the current client id — it is not known until the first `WELCOME`. */
55
+ private readonly meOf;
56
+ plain: PlainState;
57
+ private tracked;
58
+ private readonly descs;
59
+ private readonly frozen;
60
+ /** The object handed out as `room.state`; identity survives a resync. */
61
+ readonly view: AnyRecord$3;
62
+ /** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
63
+ private readonly pendingWrites;
64
+ /** The newest write tick evicted from `pendingWrites` — corrections older than it must snap. */
65
+ private evictedThroughTick;
66
+ /**
67
+ * The newest fields to leave `pendingWrites` per instance — judged into a correction, or
68
+ * evicted past the resim depth. This is the intent in force at the *start* of a resim window:
69
+ * the server holds a write's values until the next `WRITE` replaces them, so a replay that
70
+ * walks the window tick by tick starts from here, not from the newest local value.
71
+ */
72
+ private readonly baselineIntents;
73
+ constructor(ext: AnySchema,
74
+ /** Reads the current client id — it is not known until the first `WELCOME`. */
75
+ meOf: () => string);
76
+ loadSnapshot(bytes: Uint8Array): void;
77
+ /**
78
+ * Captures the field values of every pending owned write, keyed by collection then id then
79
+ * field name — call this **before** `loadSnapshot` on a resync, or the edit is gone once the
80
+ * old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
81
+ */
82
+ capturePendingWrites(): Map<string, Map<string, AnyRecord$3>>;
83
+ /**
84
+ * Writes a `capturePendingWrites` snapshot into the freshly loaded `plain` (still the raw
85
+ * decoded values — no validation, they were already validated at the time of the original
86
+ * local write) and marks every owned field dirty so the next flush resends the real edit, not
87
+ * whatever the resync snapshot happened to carry for that field.
88
+ */
89
+ applyPendingWrites(pending: Map<string, Map<string, AnyRecord$3>>): void;
90
+ private buildView;
91
+ /** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
92
+ instance(desc: CollectionDesc, id: string): unknown;
93
+ private freeze;
94
+ private entityHint;
95
+ private singletonHint;
96
+ /**
97
+ * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
98
+ * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
99
+ */
100
+ applyServerDelta(delta: Delta): void;
101
+ /**
102
+ * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
103
+ * client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
104
+ * `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
105
+ * Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
106
+ * write is clamped) still wins through the snap+replay path.
107
+ */
108
+ private replayOverAddEchoes;
109
+ private withoutOwnedUpdates;
110
+ /**
111
+ * An `add` for an instance this client will own, with its unflushed local field values folded
112
+ * back in. Returns `op` unchanged when there is nothing to preserve.
113
+ */
114
+ private preserveLocalWrites;
115
+ /**
116
+ * Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
117
+ * write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
118
+ * the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
119
+ * dirty mark survives so the next flush re-sends the *local* value, not the server's).
120
+ *
121
+ * Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
122
+ * `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
123
+ * and the local dirty marks it supersedes are cleared.
124
+ */
125
+ applyCorrection(delta: Delta, clientTick?: number): CorrectionOp[];
126
+ private desc;
127
+ /** The raw plain collection — authoritative values plus local writes (`track()` writes through). */
128
+ plainCollection(name: string): EntityCollection;
129
+ /**
130
+ * The flushed-but-unjudged write patches for one instance, oldest first, each keyed by the
131
+ * tick its `WRITE` was stamped with — for a physics entity these are exactly its
132
+ * unacknowledged intent frames (only intents are writable). The predictor's rebase walks its
133
+ * resim window tick by tick and applies the newest patch stamped at or before each tick
134
+ * (D22 part 2), starting from `baselineIntent` for the ticks before the first of them.
135
+ */
136
+ pendingWritePatches(collection: string, id: string): readonly {
137
+ tick: number;
138
+ patch: AnyRecord$3;
139
+ }[];
140
+ /**
141
+ * The intent in force for one instance at the start of the resim window: the newest fields
142
+ * that have left the pending buffer (judged into a correction's values, or evicted past the
143
+ * resim depth). `undefined` until the instance's first write leaves the buffer.
144
+ */
145
+ baselineIntent(collection: string, id: string): AnyRecord$3 | undefined;
146
+ /** Folds a write that left the pending buffer into the per-instance baseline, newest wins. */
147
+ private noteBaseline;
148
+ /** Cheap check for the flush loop: has anything been written locally since the last flush? */
149
+ get hasLocalWrites(): boolean;
150
+ /**
151
+ * The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
152
+ * dirty set either way (a write to an instance that has since been removed or handed away is
153
+ * dropped, not retried forever).
154
+ */
155
+ takeWrite(tick: number): Uint8Array | undefined;
156
+ /**
157
+ * Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
158
+ * `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
159
+ * Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
160
+ */
161
+ private retainPendingWrite;
162
+ /** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
163
+ private replayWrite;
164
+ /**
165
+ * Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
166
+ * worker restart) does not silently drop writes that were in flight.
167
+ */
168
+ remarkOwned(): void;
169
+ /** Only update ops, only instances that still exist and are still mine, never the owner bit. */
170
+ private ownedDirty;
171
+ }
172
+
173
+ /**
174
+ * Client-side physics prediction (D22 part 2, D21): a local Rapier world that simulates this
175
+ * client's own bodies — and any `predicted: true` collection's bodies — ahead of the server.
176
+ *
177
+ * ## The contract
178
+ *
179
+ * The world is built from the **shared world-builder** the game passes to
180
+ * `joinRoom({ physics })`: the same `setup` (static geometry), `bodies` (shape factories) and
181
+ * `intents` (intent → force, per step) functions the room's config uses, imported by both sides
182
+ * from one module (`irtio/world.ts` by convention — a convention, not a mechanism). The builder
183
+ * must be pure over synced inputs: no `Math.random()`, no clock, seed/level params read from
184
+ * synced state — or the two worlds are not the same world.
185
+ *
186
+ * ## The loop
187
+ *
188
+ * The local world **free-runs** on a fixed-timestep accumulator driven by render reads: each
189
+ * step applies the owner's current intent values through the `intents` hook, then steps. When
190
+ * authoritative body state arrives (a `CORRECT` for an owned body, a `DELTA` for a non-owned
191
+ * predicted one), every predicted body is **rebased** to the server's values and the world
192
+ * re-steps the client's lead — one step per tick of estimated one-way latency, replaying the
193
+ * buffered unjudged intent frames oldest-first (the write buffer holds exactly the server's
194
+ * input frames — only intents are writable on a physics entity). The lead is bounded by the
195
+ * shared resimulation depth (20, D19): beyond it the body snaps to authority and the snap is
196
+ * counted.
197
+ *
198
+ * ## What the renderer sees
199
+ *
200
+ * Not the head. The head moves once per server tick — and only when an arrival rebases the world
201
+ * onto it — while a display asks two to four times as often, so handing back the head draws a
202
+ * character running at a steady nine units a second as standing still for half the frames and
203
+ * moving at twenty for the rest. `read()` therefore keeps a short ring of recent poses per body
204
+ * and draws between the two either side of a render clock that runs on wall time, steered to sit
205
+ * about a tick behind the head. Correctness is unaffected: `room.state`, the per-tick history a
206
+ * correction is judged against, and `predictedValues` all still see the head. See bug 7 in
207
+ * `docs/bugs.md` for the measurement.
208
+ *
209
+ * ## What is deliberately absent
210
+ *
211
+ * Non-predicted dynamic bodies do not exist in the local world, and neither do `predicted: true`
212
+ * instances that fall over `maxPredictedBodies`. A predicted body that collides with either on
213
+ * the server mispredicts here — it had nothing to collide with, so it passes through and is
214
+ * snapped back by the next correction. That is the documented constraint: predict both
215
+ * (`predicted: true`, within the cap) or interpolate both.
216
+ *
217
+ * The engine itself is loaded lazily via dynamic `import()`, so a game with no physics option —
218
+ * or a bundler that code-splits — pays nothing for it.
219
+ */
220
+
221
+ type AnyRecord$2 = Record<string, unknown>;
222
+ type ClientRapierModule = typeof RAPIER;
223
+ type ClientRapierWorld = RAPIER.World;
224
+ type ClientRapierBody = RAPIER.RigidBody;
225
+ interface ClientVector3 {
226
+ readonly x: number;
227
+ readonly y: number;
228
+ readonly z: number;
229
+ }
230
+ /** What a client-side body factory returns — the same shape the room config's factories use. */
231
+ interface ClientBodySpec {
232
+ readonly body: RAPIER.RigidBodyDesc;
233
+ readonly colliders?: readonly RAPIER.ColliderDesc[];
234
+ }
235
+ /**
236
+ * Method-syntax members check bivariantly, so a builder's factory or intent hook written against
237
+ * its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
238
+ * the schema's records for that collection.
239
+ */
240
+ type ClientBodyFactory = {
241
+ factory(rapier: ClientRapierModule, instance: AnyRecord$2, id: string): ClientBodySpec;
242
+ }['factory'];
243
+ type ClientIntentHook = {
244
+ hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
245
+ }['hook'];
246
+ /**
247
+ * `joinRoom({ physics })` — the client half of the shared world-builder contract. Every function
248
+ * here should be the very export the room config imports, so "same code both sides" stays
249
+ * literally true.
250
+ */
251
+ interface ClientPhysicsOptions {
252
+ /** Must equal the room config's gravity (put it in the shared module). */
253
+ readonly gravity: ClientVector3;
254
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
255
+ readonly timestep?: number;
256
+ /** The shared static-geometry builder (the room's `physics.setup`). */
257
+ readonly setup?: (world: ClientRapierWorld, rapier: ClientRapierModule) => void;
258
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
259
+ readonly bodies?: Readonly<Record<string, ClientBodyFactory>>;
260
+ /**
261
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
262
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
263
+ */
264
+ readonly intents?: Readonly<Record<string, ClientIntentHook>>;
265
+ /**
266
+ * D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
267
+ * are **absent from the local world** — they render by interpolation, but predicted bodies
268
+ * pass straight through them, so anything a predicted body stands on or is blocked by has to
269
+ * be under the cap (warned, counted as `stats.overCap`). Default 64.
270
+ */
271
+ readonly maxPredictedBodies?: number;
272
+ /**
273
+ * A body-field correction whose every value is within this tolerance of the local prediction
274
+ * is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
275
+ * quiet. Positions compare against `epsilon` world units directly; velocity channels compare
276
+ * against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
277
+ * an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
278
+ */
279
+ readonly epsilon?: number;
280
+ /**
281
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it,
282
+ * as a half-life in milliseconds. `0` turns the smoothing off. Default 70.
283
+ *
284
+ * A rebase can move a body that has already been drawn — because the server disagreed, or
285
+ * because a newly-flushed intent changed what the last few ticks should have been. Handing
286
+ * that straight to the renderer is a step, and a step in the middle of steady motion is what
287
+ * a player calls a yank. Instead the jump is taken out of the drawn pose and put into a
288
+ * per-body offset that decays: the character keeps moving smoothly and arrives at the truth a
289
+ * moment later.
290
+ *
291
+ * This smooths the **error**, not the motion. A rate limiter on the drawn position — the
292
+ * obvious version, and the one a game writes for itself — cannot tell a correction from the
293
+ * character running, so it lags real movement too. Nothing here touches motion the simulation
294
+ * actually produced.
295
+ */
296
+ readonly smoothingHalfLifeMs?: number;
297
+ /**
298
+ * How far the drawn position may be held from the simulation while an offset eases away, in
299
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
300
+ *
301
+ * This is the teleport case: a respawn, an area change, a resync. Easing across one of those
302
+ * draws the body sliding through the level, which is worse than the step it avoids. It is the
303
+ * one number here that depends on how big a world unit is in your game.
304
+ */
305
+ readonly smoothingSnapUnits?: number;
306
+ }
307
+ /** Counters for the report and `room.prediction.stats`. */
308
+ interface PredictionStats {
309
+ /** Steps taken free-running ahead of authority. */
310
+ freeSteps: number;
311
+ /** Steps taken re-simulating after a rebase. */
312
+ resimSteps: number;
313
+ /** Rebase passes (one per authoritative arrival batch). */
314
+ rebases: number;
315
+ /** Rebases whose lead outran the resim depth: the body snapped to authority. */
316
+ snaps: number;
317
+ /** Corrections whose values matched the local prediction within epsilon. */
318
+ suppressed: number;
319
+ /**
320
+ * Non-owned predicted instances currently over the cap. They render by interpolation but have
321
+ * no body in the local world, so predicted bodies pass through them: a non-zero value on a
322
+ * collection anything stands on is a gameplay bug, not a fidelity tradeoff.
323
+ */
324
+ overCap: number;
325
+ /** Microseconds spent in the last rebase's re-steps. */
326
+ lastResimMicros: number;
327
+ /**
328
+ * The largest render-error offset currently being eased away, in world units — how far the
329
+ * furthest-off predicted body is being drawn from where the simulation puts it, so that a
330
+ * re-simulation did not arrive as a step (`smoothingHalfLifeMs`). It rises on a correction and
331
+ * falls back towards zero; what is unhealthy is a value that *stays* up, which means the
332
+ * client is being corrected as fast as it can absorb it.
333
+ */
334
+ smoothing: number;
335
+ /**
336
+ * Ticks between the stamp a `WRITE` carries and the server tick it was applied at, smoothed
337
+ * (bug 1). The client stamps on its predicted clock, which leads authority, so this is how far
338
+ * *early* a naive replay would put every pending intent. 0 until a server that reports the
339
+ * applied tick judges a stamped write.
340
+ */
341
+ stampGap: number;
342
+ }
343
+ declare class PhysicsPredictor {
344
+ private readonly store;
345
+ private readonly options;
346
+ private readonly meOf;
347
+ private readonly rttOf;
348
+ private readonly tickIntervalOf;
349
+ private readonly log;
350
+ readonly stats: PredictionStats;
351
+ private rapier;
352
+ private world;
353
+ private readonly bodies;
354
+ /** Physics-backed entity collections, in schema order. */
355
+ private readonly collections;
356
+ private readonly warned;
357
+ /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
358
+ private readonly overCapHigh;
359
+ /** Authority arrived since the last frame: rebase before free-running. */
360
+ private authorityDirty;
361
+ private accumulatorMs;
362
+ private lastNow;
363
+ private lastFrameNow;
364
+ private failed;
365
+ /**
366
+ * The local world's head, on the server's tick timeline: the authoritative tick the last
367
+ * rebase started from plus the lead it re-stepped, then one per free-run step.
368
+ */
369
+ private headTick;
370
+ /** The newest server tick any authority has been seen for. `headTick`'s base. */
371
+ private authorityTick;
372
+ /** The newest tick a pose has been stored for. `-1` until the first capture. */
373
+ private curTick;
374
+ /**
375
+ * Where the renderer is, as a fractional tick. Advanced by wall time every frame and steered
376
+ * to sit about a tick behind `curTick`; `read()` draws between the stored poses either side
377
+ * of it.
378
+ */
379
+ private renderTick;
380
+ /** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
381
+ private readonly scratch;
382
+ /** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
383
+ private readonly qa;
384
+ private readonly qb;
385
+ private readonly qc;
386
+ /**
387
+ * Per owned body: the server tick its authoritative record is *from* (the tick of the last
388
+ * `CORRECT` that touched it), and the per-tick prediction history — after every local step,
389
+ * the body's channels are recorded under the tick that step predicted. A correction for tick T
390
+ * is then judged against the client's prediction **for tick T**, not against the current head
391
+ * of the simulation (which legitimately leads authority by the whole latency — comparing
392
+ * against it would report the lead as misprediction).
393
+ */
394
+ private readonly authorityTicks;
395
+ /** Smoothed stamp-minus-applied gap; see `noteWriteApplied`. */
396
+ private stampGap;
397
+ private gapMeasured;
398
+ /** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
399
+ private gapSampledThrough;
400
+ private readonly predictedTicks;
401
+ private readonly history;
402
+ /** History kept per body — comfortably past the resim depth. */
403
+ private static readonly HISTORY_TICKS;
404
+ constructor(ext: AnySchema, store: ClientStore, options: ClientPhysicsOptions, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
405
+ get epsilon(): number;
406
+ /** `true` once the engine is loaded and the local world exists. */
407
+ get ready(): boolean;
408
+ /**
409
+ * Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
410
+ * interpolation/authority — joining is never blocked on 2.9 MB of WASM.
411
+ */
412
+ start(): Promise<void>;
413
+ free(): void;
414
+ /** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
415
+ reset(): void;
416
+ /**
417
+ * Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one).
418
+ *
419
+ * `tick` is the server tick it carried, and it is the base of the predictor's own head clock —
420
+ * which the renderer's interpolation is paced against. It has to be the *world's* clock rather
421
+ * than any one body's: a client with nothing of its own to own still draws a world full of
422
+ * predicted bodies.
423
+ */
424
+ noteAuthority(tick?: number): void;
425
+ /** The session tells us which server tick one body's authoritative record is from. */
426
+ noteAuthorityTick(collection: string, id: string, tick: number): void;
427
+ /**
428
+ * A `CORRECT` reported both halves of a judged write: the stamp the client put on it and the
429
+ * server tick it was actually applied at (bug 1). The gap between them is the systematic error
430
+ * in the rebase's replay, and it is not zero even on a loopback: the stamp is the predictor's
431
+ * *head* (authority + lead), while the server applies on arrival, at a tick barely past the
432
+ * authority the client is replaying from. Replaying at the stamp therefore held the previous
433
+ * intent for `gap` ticks the server had already simulated under the new one — the overshoot on
434
+ * every key release, sized `gap x speed`.
435
+ *
436
+ * Smoothed, because it rides on delivery jitter and the measurement is one sample per judged
437
+ * write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
438
+ * authority is on the session's bare write counter rather than the server's tick stream, and
439
+ * differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
440
+ */
441
+ noteWriteApplied(stampTick: number, appliedTick: number): void;
442
+ /** Does the local world currently simulate `collection[id]`? */
443
+ has(collection: string, id: string): boolean;
444
+ /**
445
+ * Is a correction's every value within the suppression tolerance of the prediction it judges?
446
+ * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
447
+ * channels against `epsilon / timestep`, because a velocity disagreement matters by what it
448
+ * moves in one tick.
449
+ */
450
+ withinEpsilon(desc: CollectionDesc, fields: readonly string[], patch: AnyRecord$2, predicted: AnyRecord$2): boolean;
451
+ /** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
452
+ predictsCollection(name: string): boolean;
453
+ /**
454
+ * Advances the local world to `now`. Driven by render reads (one pass per timestamp), so a
455
+ * draw loop — or a bot's render sampling — is the clock; there is no timer.
456
+ */
457
+ frame(now: number): void;
458
+ /**
459
+ * The head moved: store every predicted body's pose under the tick it now holds.
460
+ *
461
+ * A tick can be simulated more than once — a `DELTA` and a `CORRECT` both arrive for the same
462
+ * tick and each rebases — so the slot is simply rewritten with the better answer. It can also
463
+ * move backwards, when a free-run step is discarded by the rebase behind it; `curTick` does
464
+ * not follow it down, because moving the newest label backwards would drag the segment the
465
+ * renderer is crossing backwards with it.
466
+ */
467
+ private capturePoses;
468
+ /**
469
+ * Advance the render clock by one frame of wall time, steering it towards a target distance
470
+ * behind the head rather than clamping it there.
471
+ *
472
+ * `renderTick` and `curTick` agree on rate — one tick per tick period — but not on phase, and
473
+ * the phase error is delivery jitter: authority arrives when the network hands it over, not
474
+ * every 33 ms however steady the server is. Left alone the two would wander apart, so the
475
+ * clock is steered, by rate rather than by position. What the network did to a packet is not
476
+ * something the player's character should be seen doing.
477
+ */
478
+ private advanceRenderClock;
479
+ /**
480
+ * Keep the render clock inside the span there are poses for.
481
+ *
482
+ * Both ends are real. At `curTick` there is nothing further to draw towards, so a late arrival
483
+ * holds the pose rather than extrapolating motion the next tick would have to take back — the
484
+ * same trade that sized the prediction lead (bug 1). At the far end the ring has recycled the
485
+ * slot and the pose is genuinely gone. Between them the clock floats, and the rate trim above
486
+ * is what keeps it from spending its time against either stop.
487
+ */
488
+ private holdRenderTick;
489
+ /** `smoothingHalfLifeMs`, or 0 when the game turned the smoothing off. */
490
+ private halfLifeMs;
491
+ /** Remember where every body is being drawn, before this frame re-simulates anything. */
492
+ private snapshotDrawn;
493
+ /**
494
+ * Take whatever the re-simulation moved the drawn pose by and put it in the offset instead.
495
+ *
496
+ * This is the whole of correction smoothing, and it is smoothing the *error*: the difference
497
+ * measured here is between two answers to the same question — where is this body at the render
498
+ * clock — asked either side of a rebase. Motion the simulation produced is not in it, because
499
+ * the render clock advanced before the snapshot. So real movement is never slowed, which is
500
+ * the thing a rate limiter on the drawn position cannot promise: it sees a position that moved
501
+ * and has no way to know whether the body ran or was corrected.
502
+ *
503
+ * It absorbs more than server disagreement. A newly flushed intent changes what the last few
504
+ * ticks should have been, and the replay hands that over as a jump the same way; so does the
505
+ * render clock being pulled back inside the poses it has. All of it is the same defect from
506
+ * the player's side — the character was drawn somewhere it now turns out it was not — and all
507
+ * of it eases away here.
508
+ */
509
+ private absorbJump;
510
+ /**
511
+ * Ease every offset towards zero. Exponential, so the rate is proportional to the error and
512
+ * there is no world-scale speed to pick: a correction worth a tenth of a unit is worked off
513
+ * gently and one worth a whole unit is not left hanging around for a second.
514
+ */
515
+ private decayOffsets;
516
+ private clearOffset;
517
+ /**
518
+ * The pose to draw one body at: the stored poses either side of the render clock, blended.
519
+ * Falls back to the body's live transform when the ring cannot bracket it — before the first
520
+ * capture, and for a body that appeared this frame.
521
+ */
522
+ private rawPose;
523
+ /** `rawPose` with the render-error offset applied — what the draw loop actually gets. */
524
+ private renderPose;
525
+ /**
526
+ * The predicted record for `collection[id]`: the authoritative record (which already carries
527
+ * local intent writes — `track()` writes through to plain state) with the body-mapped channels
528
+ * replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
529
+ * reads is what the wire would carry.
530
+ *
531
+ * Drawn *between* two simulated ticks, not at the head. The local world steps at the room's
532
+ * tick rate, and only when authority arrives to rebase it; a display runs at two to four times
533
+ * that and asks on every frame. Handing back the head means the answer changes on some frames
534
+ * and not others, so a character running at a steady nine units a second is drawn standing
535
+ * still for half of them and moving at twenty for the rest. It is the player's own character
536
+ * answering their own key, and it reads as the game hitching.
537
+ *
538
+ * This is a *render* read. `room.state` and every correctness path — `predictedValues`, the
539
+ * per-tick history a correction is judged against — still see the head exactly as before.
540
+ */
541
+ read(desc: CollectionDesc, id: string, base: AnyRecord$2): AnyRecord$2;
542
+ /**
543
+ * The local world's current values for `fields` of one body — what a correction is judged
544
+ * against (`previous` on the `correct` event, and the epsilon-suppression comparison).
545
+ */
546
+ predictedValues(desc: CollectionDesc, id: string, fields: readonly string[], atTick?: number): AnyRecord$2 | undefined;
547
+ /**
548
+ * Mirrors the local world's bodies onto the instances this client predicts: every physics
549
+ * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
550
+ * (collection order, then insertion order — the same stated iteration guarantee the server
551
+ * follows, so which bodies fall over the cap is deterministic).
552
+ *
553
+ * Everything else — non-`predicted` collections, and `predicted` instances over the cap — gets
554
+ * no body and no collider here. Those instances still render (the interpolation path reads
555
+ * authoritative state directly), but nothing in the local world can touch them.
556
+ */
557
+ private reconcileBodies;
558
+ private createBody;
559
+ /**
560
+ * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
561
+ * world right now, so a predicted body walks through them. Warn on every new high-water mark
562
+ * per collection — a game that grows past the cap mid-session hears about it, and a count that
563
+ * oscillates around one level does not turn the console into a log.
564
+ */
565
+ private warnOverCap;
566
+ private warnOnce;
567
+ /**
568
+ * The client's lead over authority, in ticks: the one-way transit the authority in hand has
569
+ * already spent in flight, rounded to the nearest tick. Nothing more.
570
+ *
571
+ * Every tick of lead beyond that transit is a tick of motion the client draws on the
572
+ * assumption that the input it is holding will still be held when the server gets there. On a
573
+ * release that assumption is wrong by construction, and the invented travel is handed back as
574
+ * a backwards yank — bug 1's "keeps moving after key up and then snaps back". It was
575
+ * `ceil(owd) + 1`, which on a fast link is two whole ticks of margin over a transit of nearly
576
+ * zero. Measured on the arena fixture at no injected latency (release overshoot of a 6 u/s
577
+ * held-input character, `physics-predict.test.ts`):
578
+ *
579
+ * | lead | drawn overshoot past the server's stop | worst backwards step |
580
+ * | --- | --- | --- |
581
+ * | `ceil(owd) + 1` (was) | 0.585 u | -0.585 u |
582
+ * | `max(1, round(owd))` (is) | 0.293 u | -0.292 u |
583
+ * | `round(owd)`, floor 0 | 0.0008 u | -0.0008 u |
584
+ *
585
+ * One tick of running is 0.3 u there: the overshoot is the lead, in ticks, and nothing else.
586
+ * On a link with no transit to cover, every tick of lead is a tick of guessing that the key is
587
+ * still held.
588
+ *
589
+ * The floor of 1 is not margin, it is the smallest lead that is still prediction. At 0 the
590
+ * rebase takes no steps: the local world is pinned to the authority it just received, which is
591
+ * already a tick old, so the client responds to its own input only when the next `CORRECT`
592
+ * arrives and every moving body reads as mispredicted by one tick of motion. Measured: the
593
+ * bot suites' correction-storm invariant fires immediately (21 corrections/s per bot against a
594
+ * threshold of 5 in `packages/cli/test/simulate-physics.test.ts`). One tick of speculation is
595
+ * the price of predicting at all; two was the bug.
596
+ *
597
+ * Erring low is also the cheap direction — a lead shorter than the transit means authority
598
+ * arrives slightly *ahead* of the prediction, and a correction that pulls the character the way
599
+ * it is already going is the one nobody can see.
600
+ *
601
+ * Measured earlier the same day (dive e2e): a *full*-round-trip lead was worse again
602
+ * (-1.4 to -1.8 u worst backwards step vs -0.6 at half), which is the same finding from the
603
+ * other end. `stats.stampGap` is the running check on all of this — it reports how far ahead
604
+ * of the server's application the stamps still land, and it should sit at 0.
605
+ */
606
+ private leadTicks;
607
+ private timestepMs;
608
+ /**
609
+ * Rebase + re-step: snap every predicted body to the authoritative record (server values —
610
+ * `DELTA` for non-owned bodies, `CORRECT` for owned ones; body fields are never client-
611
+ * written, so plain state holds exactly what the server said), then re-step the world by the
612
+ * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
613
+ * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
614
+ * judged write) before the first of them. Bounded by the shared resim depth: an outrun lead
615
+ * snaps to authority and counts (`stats.snaps`).
616
+ */
617
+ private rebase;
618
+ /**
619
+ * The tick a `WRITE` flushed right now should be stamped with: one past the newest predicted
620
+ * head across owned bodies — the first tick the new intent can affect. That puts the stamp on
621
+ * the same clock as `authorityTicks` and the resim window (the server's tick stream), which is
622
+ * what lets the rebase walk pending writes by tick instead of by array index. `undefined`
623
+ * until the engine runs or while nothing is owned; the session falls back to its counter.
624
+ */
625
+ stampTick(): number | undefined;
626
+ /** After every step: advance each owned body's prediction clock and remember its channels. */
627
+ private recordStep;
628
+ /** Returns the number of steps taken, so the caller knows whether the head moved. */
629
+ private freeRun;
630
+ /**
631
+ * Applies intents for every owned predicted body before one step: the intent in force at the
632
+ * tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
633
+ * current values (free-run: plain state carries the newest local intent writes, which is
634
+ * correct there because free-run steps are the ticks *after* every buffered write).
635
+ */
636
+ private applyIntents;
637
+ /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
638
+ private applyRecord;
639
+ private isF32;
640
+ }
641
+
13
642
  /**
14
643
  * A client-side entity collection: the read API of `@irtio/schema`'s `ReadonlyCollection`, plus
15
644
  * index sugar so `state.players[room.me]` reads the same as `state.players.get(room.me)`.
@@ -56,6 +685,11 @@ interface Correction {
56
685
  readonly fields: readonly string[];
57
686
  /** The server's values for those fields (already applied to `room.state`). */
58
687
  readonly patch: Readonly<Record<string, unknown>>;
688
+ /**
689
+ * The local (predicted) values of `fields` right before the snap. Misprediction magnitude is
690
+ * the numeric distance between `previous` and `patch`, per field.
691
+ */
692
+ readonly previous: Readonly<Record<string, unknown>>;
59
693
  readonly tick: number;
60
694
  /**
61
695
  * The client write tick the server judged (D19, week 8): every local write through it is
@@ -70,6 +704,19 @@ interface Correction {
70
704
  * writes) — everything it named snapped to the server's values, nothing was replayed.
71
705
  */
72
706
  readonly snapped: boolean;
707
+ /**
708
+ * D22: every corrected field is a simulation-owned body field this client does **not**
709
+ * predict — not a disagreement, just server-authoritative body state reaching its owner (once
710
+ * per tick, by design). When the client predicts the body, this is `false` and the correction
711
+ * is a real misprediction: `previous` holds the local world's predicted values.
712
+ */
713
+ readonly simulation: boolean;
714
+ /**
715
+ * D22 part 2: a predicted body's correction whose every value matched the local prediction
716
+ * within the epsilon — authority applied, but there was nothing to disagree about. Suppressed
717
+ * corrections keep a healthy predicted body's steady state quiet.
718
+ */
719
+ readonly suppressed: boolean;
73
720
  }
74
721
  interface RoomEvents {
75
722
  status: Status;
@@ -134,9 +781,30 @@ interface JoinOptions<S, Role extends string = string> {
134
781
  * (which is what `irtio dev` uses when there is no `irtio.json`).
135
782
  */
136
783
  readonly key?: string;
784
+ /**
785
+ * Week 13 (D27): a JWT asserting who this player is, minted by your server with the project's
786
+ * signing secret. `ctx.playerId` becomes the token's `sub`, and a `role` claim overrides
787
+ * `role` here. A function is called before every HELLO — reconnects included — so it can hand
788
+ * back a fresh token when the old one nears expiry. Omitted ⇒ the key-only join, unchanged.
789
+ */
790
+ readonly token?: string | (() => string | Promise<string>);
137
791
  readonly onStatus?: (status: Status) => void;
138
792
  /** Hard cap on the owned-write flush window, in ms. Default 50. */
139
793
  readonly writeIntervalMs?: number;
794
+ /**
795
+ * How far behind arrival `room.render` draws non-owned entities, in ms (D20). Default:
796
+ * `2 × tickIntervalMs` (the room's tick interval, learned from `WELCOME`), floored at 50 ms.
797
+ */
798
+ readonly interpDelayMs?: number;
799
+ /**
800
+ * D22 part 2: the client half of the shared world-builder contract — the same `setup`,
801
+ * `bodies` and `intents` functions the room's `physics` config imports (by convention from
802
+ * `irtio/world.ts`), plus `gravity`. With it, this client simulates its own bodies (and any
803
+ * `predicted: true` collection's, capped) ahead in a local world; without it, body-backed
804
+ * entities interpolate like everything else. The engine loads lazily — a bundler that
805
+ * code-splits keeps Rapier out of the critical path entirely.
806
+ */
807
+ readonly physics?: ClientPhysicsOptions;
140
808
  /** @internal */
141
809
  readonly transport?: Transport;
142
810
  /** @internal */
@@ -163,6 +831,41 @@ interface JoinRelayOptions {
163
831
  type MessageTarget = 'all' | string | {
164
832
  readonly role: string;
165
833
  };
834
+ /**
835
+ * Default D21 cap on non-owned predicted bodies per client.
836
+ *
837
+ * Sized against measurement, not caution: 41 dynamic bodies stacked in contact step in 0.005 to
838
+ * 0.007 ms (`games/dive/spike`, 2026-08-26), and a rebase re-steps the local world once per lead
839
+ * tick per authoritative tick — about 0.5 ms per second of wall clock at 30Hz with a 3-tick lead.
840
+ * Body count is not what makes prediction expensive, and the cost of a cap set too low is not
841
+ * saved CPU: over-cap instances are **absent from the local world** (see `reconcileBodies`), so
842
+ * a predicted body passes through them.
843
+ */
844
+ declare const MAX_PREDICTED_BODIES = 64;
845
+ /** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
846
+ declare const PREDICTION_EPSILON = 0.05;
847
+ /**
848
+ * Default half-life for render-error smoothing, milliseconds (see
849
+ * `ClientPhysicsOptions.smoothingHalfLifeMs`). About two ticks at 30Hz: long enough that a
850
+ * correction is a settle rather than a step, short enough that the drawn position is back on the
851
+ * simulation within a fifth of a second.
852
+ */
853
+ declare const SMOOTHING_HALF_LIFE_MS = 70;
854
+ /**
855
+ * Default distance past which a render-error offset is dropped rather than eased away, world
856
+ * units (see `ClientPhysicsOptions.smoothingSnapUnits`). A respawn, an area change or a session
857
+ * resync moves a body somewhere else entirely, and sliding into it is worse than arriving.
858
+ * Four units is a guess at world scale and the one number here a game may need to change.
859
+ */
860
+ declare const SMOOTHING_SNAP_UNITS = 4;
861
+ /** `room.prediction` (D22 part 2). */
862
+ interface PredictionStatus {
863
+ /** The engine is loaded and the local world exists. */
864
+ readonly active: boolean;
865
+ /** Is `collection[id]` currently simulated in the local world? */
866
+ predicts(collection: string, id: string): boolean;
867
+ readonly stats: PredictionStats;
868
+ }
166
869
  /**
167
870
  * `room.call.<rpc>(params)` — every server RPC the builder declared, plus the one built-in.
168
871
  * `requestOwnership` takes the entity/id pair positionally and unwraps `{ granted }`.
@@ -176,7 +879,10 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
176
879
  readonly me: string;
177
880
  /** The room code (from `WELCOME`). */
178
881
  readonly id: string;
179
- /** Shareable URL carrying `?room=<id>`. */
882
+ /**
883
+ * Shareable URL carrying `?room=<id>`, with per-client params (`role`) stripped so it is safe
884
+ * to hand to somebody else. The address bar keeps them.
885
+ */
180
886
  readonly link: string;
181
887
  /** The last server tick this client saw. */
182
888
  readonly tick: number;
@@ -184,8 +890,25 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
184
890
  /** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
185
891
  readonly rtt: number;
186
892
  readonly state: ClientState<S, Role>;
893
+ /**
894
+ * The render read path (D20): same shapes as `room.state`, but non-owned entities are
895
+ * interpolated at `now − interpDelayMs` (numeric fields lerp, everything else steps) while
896
+ * predicted bodies are drawn between the two most recent ticks of the local physics world.
897
+ * Either way the value advances with the frame clock rather than with arrivals or with the
898
+ * tick rate. A draw loop opts in by one substitution —
899
+ * `room.state.players` → `room.render.players`. Tests and game logic keep reading
900
+ * `room.state`, which stays authoritative.
901
+ */
902
+ readonly render: ClientState<S, Role>;
187
903
  /** Built-in presence, ordered by join. */
188
904
  readonly clients: readonly PresenceRecord[];
905
+ /**
906
+ * Physics-prediction status, present when the join passed `physics` and the schema has
907
+ * body-backed collections (D22 part 2). `active` flips true once the engine loads; `predicts`
908
+ * says whether one instance currently reads from the local world; `stats` carries the
909
+ * counters the bot runtime and the demos report.
910
+ */
911
+ readonly prediction?: PredictionStatus;
189
912
  readonly call: RoomCallProxy<S>;
190
913
  /** Convenience alias for `room.call.requestOwnership`. */
191
914
  requestOwnership(entity: string, id: string): Promise<boolean>;
@@ -260,141 +983,60 @@ declare function defaultScheduler(): Scheduler;
260
983
  declare const webSocketTransport: Transport;
261
984
 
262
985
  /**
263
- * The client's state layer.
264
- *
265
- * One plain `PlainState` (decoded from the join snapshot, advanced by every `DELTA`) with two
266
- * views over it:
267
- *
268
- * - **owned instances** the `track()` proxy for that record, so a write normalizes exactly like
269
- * the server would (f32 quantized, ranges and string sizes enforced by a throw) and marks a
270
- * dirty set the flush window turns into one `WRITE`;
271
- * - **everything else** → `frozenProxy`, which ignores writes and warns once with the ownership
272
- * hint.
986
+ * The interpolated render read path (D20, week 8): `room.render.<collection>` mirrors
987
+ * `room.state`'s shapes, but non-owned entities are rendered at `now − interpDelayMs`, lerping
988
+ * numeric fields between the two bracketing DELTAs and stepping everything else (strings, bools,
989
+ * enums, refs, lists). Predicted bodies — owned or `predicted: true` — come from the local
990
+ * physics world instead, drawn between the two most recent simulated ticks rather than at the
991
+ * simulation head, so they too advance with the frame clock and not with whatever rate their
992
+ * source happens to update at (`physics.ts`, `read`). One read path serves the whole draw loop.
273
993
  *
274
- * Which one you get is decided per `get()`, so an ownership change needs no bookkeeping — but the
275
- * object identity is not stable across it, which is why `state.x.get(id)` must be re-read after a
276
- * grab (documented in the README).
277
- *
278
- * The server-wins rule lives here too: a `DELTA`'s update ops are dropped field-wise for
279
- * instances this client owns (adds, removes and owner changes still apply), while a `CORRECT`
280
- * applies in full and clears the matching local dirty marks.
994
+ * `room.state` stays authoritative and untouched: tests, bots and existing code see exactly what
995
+ * they saw before. Entities appear and disappear at the buffered time; past the newest delta the
996
+ * value holds (no extrapolation) and the starvation is counted. A collection declared
997
+ * `interpolate: false` (and every singleton) reads straight through to the authoritative view.
281
998
  */
282
999
 
283
1000
  type AnyRecord$1 = Record<string, unknown>;
284
- /**
285
- * Resimulation depth cap (D19): how many flushed-but-unjudged `WRITE`s the client retains for
286
- * replay after a `CORRECT`. A correction older than the retained window snaps everything it
287
- * corrects and counts it (`snapped`); buffer memory is bounded by the same window.
288
- */
289
- declare const RESIM_DEPTH = 20;
290
- /** One correction op, as the `'correct'` event sees it. */
291
- interface CorrectionOp {
292
- readonly collection: string;
293
- readonly id: string;
294
- readonly fields: readonly string[];
295
- readonly patch: AnyRecord$1;
296
- readonly tick: number;
297
- /**
298
- * The client write tick the server judged (D19), absent against a pre-week-8 server. Local
299
- * writes newer than it were re-applied on top of the correction.
300
- */
301
- readonly clientTick?: number;
302
- /** How many pending local writes were re-applied over this correction's values. */
303
- readonly replayed: number;
304
- /** `true` when the correction outran the resim window and everything it named snapped. */
305
- readonly snapped: boolean;
306
- }
307
- declare class ClientStore {
308
- readonly ext: AnySchema;
309
- /** Reads the current client id — it is not known until the first `WELCOME`. */
1001
+ declare class RenderStore {
1002
+ private readonly ext;
1003
+ private readonly store;
310
1004
  private readonly meOf;
311
- plain: PlainState;
312
- private tracked;
1005
+ private readonly now;
1006
+ private readonly delayMs;
1007
+ /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
1008
+ private readonly buffers;
313
1009
  private readonly descs;
314
- private readonly frozen;
315
- /** The object handed out as `room.state`; identity survives a resync. */
1010
+ /** The object handed out as `room.render`; identity survives a resync. */
316
1011
  readonly view: AnyRecord$1;
317
- /** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
318
- private readonly pendingWrites;
319
- /** The newest write tick evicted from `pendingWrites` corrections older than it must snap. */
320
- private evictedThroughTick;
321
- constructor(ext: AnySchema,
322
- /** Reads the current client id — it is not known until the first `WELCOME`. */
323
- meOf: () => string);
324
- loadSnapshot(bytes: Uint8Array): void;
325
- /**
326
- * Captures the field values of every pending owned write, keyed by collection then id then
327
- * field name — call this **before** `loadSnapshot` on a resync, or the edit is gone once the
328
- * old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
329
- */
330
- capturePendingWrites(): Map<string, Map<string, AnyRecord$1>>;
1012
+ /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
1013
+ starved: number;
1014
+ /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
1015
+ private predictor;
1016
+ constructor(ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number);
331
1017
  /**
332
- * Writes a `capturePendingWrites` snapshot into the freshly loaded `plain` (still the raw
333
- * decoded values no validation, they were already validated at the time of the original
334
- * local write) and marks every owned field dirty so the next flush resends the real edit, not
335
- * whatever the resync snapshot happened to carry for that field.
1018
+ * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
1019
+ * the world is visible immediately (the join snapshot is the render baseline, not a change to
1020
+ * ease towards).
336
1021
  */
337
- applyPendingWrites(pending: Map<string, Map<string, AnyRecord$1>>): void;
1022
+ seedSnapshot(): void;
1023
+ /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
1024
+ recordDelta(delta: Delta): void;
1025
+ private bufferFor;
1026
+ private renderTime;
1027
+ attachPredictor(predictor: PhysicsPredictor): void;
1028
+ /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
1029
+ get(desc: CollectionDesc, id: string): unknown;
1030
+ /** Is `collection[id]` visible at the render clock? */
1031
+ has(desc: CollectionDesc, id: string): boolean;
1032
+ /** The authoritative owner — ownership is fact, not a rendered value. */
1033
+ ownerOf(desc: CollectionDesc, id: string): string | undefined;
1034
+ /** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
1035
+ ids(desc: CollectionDesc): IterableIterator<string>;
1036
+ /** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
1037
+ private prune;
1038
+ private gc;
338
1039
  private buildView;
339
- /** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
340
- instance(desc: CollectionDesc, id: string): unknown;
341
- private freeze;
342
- private entityHint;
343
- private singletonHint;
344
- /**
345
- * Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
346
- * (server wins only through `CORRECT`); adds, removes and owner changes always apply.
347
- */
348
- applyServerDelta(delta: Delta): void;
349
- /**
350
- * The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
351
- * client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
352
- * `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
353
- * Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
354
- * write is clamped) still wins through the snap+replay path.
355
- */
356
- private replayOverAddEchoes;
357
- private withoutOwnedUpdates;
358
- /**
359
- * An `add` for an instance this client will own, with its unflushed local field values folded
360
- * back in. Returns `op` unchanged when there is nothing to preserve.
361
- */
362
- private preserveLocalWrites;
363
- /**
364
- * Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
365
- * write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
366
- * the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
367
- * dirty mark survives so the next flush re-sends the *local* value, not the server's).
368
- *
369
- * Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
370
- * `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
371
- * and the local dirty marks it supersedes are cleared.
372
- */
373
- applyCorrection(delta: Delta, clientTick?: number): CorrectionOp[];
374
- private desc;
375
- /** Cheap check for the flush loop: has anything been written locally since the last flush? */
376
- get hasLocalWrites(): boolean;
377
- /**
378
- * The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
379
- * dirty set either way (a write to an instance that has since been removed or handed away is
380
- * dropped, not retried forever).
381
- */
382
- takeWrite(tick: number): Uint8Array | undefined;
383
- /**
384
- * Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
385
- * `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
386
- * Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
387
- */
388
- private retainPendingWrite;
389
- /** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
390
- private replayWrite;
391
- /**
392
- * Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
393
- * worker restart) does not silently drop writes that were in flight.
394
- */
395
- remarkOwned(): void;
396
- /** Only update ops, only instances that still exist and are still mine, never the owner bit. */
397
- private ownedDirty;
398
1040
  }
399
1041
 
400
1042
  /**
@@ -409,8 +1051,13 @@ type AnyRecord = Record<string, unknown>;
409
1051
  type ClientImpl = (params: AnyRecord) => unknown;
410
1052
  /** Default hard cap on the owned-write flush window. */
411
1053
  declare const DEFAULT_WRITE_INTERVAL_MS = 50;
412
- /** How often the client pings for `room.rtt`. */
413
- declare const PING_INTERVAL_MS = 10000;
1054
+ /**
1055
+ * How often the client pings for `room.rtt`. Two seconds rather than ten (bug 4): the prediction
1056
+ * lead is re-derived from `rtt` on every rebase, so the interval is not just how fresh a
1057
+ * diagnostic number is — it is how long one measurement gets to size the client's simulation.
1058
+ * A PING is a 4-byte payload; five of them a minute is not the reason a room costs anything.
1059
+ */
1060
+ declare const PING_INTERVAL_MS = 2000;
414
1061
  /** How long an outbound RPC waits for its `REPLY` before rejecting. */
415
1062
  declare const CALL_TIMEOUT_MS = 10000;
416
1063
  interface SessionOptions {
@@ -419,10 +1066,21 @@ interface SessionOptions {
419
1066
  readonly url: string;
420
1067
  readonly roomId: string;
421
1068
  readonly key: string;
1069
+ /**
1070
+ * Week 13 (D27): a JWT asserting who this player is, minted by YOUR server with the project's
1071
+ * signing secret (`irtio keys jwt-secret`). A function is called before every HELLO — every
1072
+ * reconnect included — so a fresh token can be supplied when the old one nears its `exp`; a
1073
+ * resume that outlives its token would otherwise be refused with `E_TOKEN_EXPIRED`.
1074
+ */
1075
+ readonly token?: string | (() => string | Promise<string>) | undefined;
422
1076
  readonly role?: string | undefined;
423
1077
  readonly name?: string | undefined;
424
1078
  readonly rpc?: Readonly<Record<string, ClientImpl>> | undefined;
425
1079
  readonly writeIntervalMs?: number | undefined;
1080
+ /** Render delay for `room.render` (D20). Default `max(50, 2 × tickIntervalMs)`. */
1081
+ readonly interpDelayMs?: number | undefined;
1082
+ /** The shared world-builder half the client predicts with (D22 part 2). */
1083
+ readonly physics?: ClientPhysicsOptions | undefined;
426
1084
  readonly transport?: Transport | undefined;
427
1085
  readonly scheduler?: Scheduler | undefined;
428
1086
  readonly onFrame?: FrameHook | undefined;
@@ -434,6 +1092,14 @@ declare class Session {
434
1092
  private readonly options;
435
1093
  readonly ext: AnySchema;
436
1094
  readonly store: ClientStore;
1095
+ readonly render: RenderStore;
1096
+ /** `true` when the join passed `physics` and the schema has body-backed collections. */
1097
+ readonly predictionRequested: boolean;
1098
+ /**
1099
+ * Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
1100
+ * engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
1101
+ */
1102
+ predictor: PhysicsPredictor | undefined;
437
1103
  /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
438
1104
  private readonly rpcSchema;
439
1105
  private readonly transport;
@@ -445,9 +1111,11 @@ declare class Session {
445
1111
  roomId: string;
446
1112
  tick: number;
447
1113
  /**
448
- * The client-local write counter (D19): +1 per flushed `WRITE`, stamped in its delta header.
1114
+ * The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
1115
+ * prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
1116
+ * what lets the rebase replay pending writes by tick — and otherwise a bare counter.
449
1117
  * Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
450
- * for this client survives the grace window too.
1118
+ * for this client survives the grace window too, and only ever moves forward.
451
1119
  */
452
1120
  writeTick: number;
453
1121
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
@@ -455,6 +1123,19 @@ declare class Session {
455
1123
  rtt: number;
456
1124
  status: Status;
457
1125
  private socket;
1126
+ /**
1127
+ * Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
1128
+ * `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
1129
+ *
1130
+ * Needed because a reconnecting session holds a socket that exists and is *not* sendable:
1131
+ * `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
1132
+ * `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
1133
+ * test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
1134
+ * connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
1135
+ * where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
1136
+ * window to be wide; it never opens against a local dev server.
1137
+ */
1138
+ private socketOpen;
458
1139
  private resumeToken;
459
1140
  private joined;
460
1141
  private left;
@@ -470,6 +1151,11 @@ declare class Session {
470
1151
  private settleJoin;
471
1152
  private failJoin;
472
1153
  constructor(options: SessionOptions);
1154
+ /**
1155
+ * The `room.render` delay (D20): the explicit option, else twice the room's tick interval
1156
+ * (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
1157
+ */
1158
+ get interpDelayMs(): number;
473
1159
  /** Connects and resolves on the first `WELCOME`; a fatal `ERROR` before it rejects. */
474
1160
  start(): Promise<Session>;
475
1161
  private connect;
@@ -487,6 +1173,10 @@ declare class Session {
487
1173
  private onWelcome;
488
1174
  private onDelta;
489
1175
  private onCorrect;
1176
+ private descsByName;
1177
+ private descOfCollection;
1178
+ /** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
1179
+ private capturePredicted;
490
1180
  private onError;
491
1181
  private onPong;
492
1182
  private onMsg;
@@ -497,6 +1187,7 @@ declare class Session {
497
1187
  * inside a window leaves as a single `WRITE`.
498
1188
  */
499
1189
  private armFlush;
1190
+ private sendPing;
500
1191
  private armPing;
501
1192
  /** Sends every pending owned write now. */
502
1193
  flush(): void;
@@ -553,4 +1244,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
553
1244
  */
554
1245
  declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
555
1246
 
556
- export { CALL_TIMEOUT_MS, type ClientCollection, type ClientState, ClientStore, type Correction, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, type FrameHook, type JoinOptions, type JoinRelayOptions, type MessageTarget, PING_INTERVAL_MS, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, defaultScheduler, joinRelay, joinRoom, linkForUrl, resolveUrl, roomIdFrom, webSocketTransport };
1247
+ export { CALL_TIMEOUT_MS, type ClientBodySpec, type ClientCollection, type ClientPhysicsOptions, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector3, type Correction, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, type FrameHook, type JoinOptions, type JoinRelayOptions, MAX_PREDICTED_BODIES, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PredictionStats, type PredictionStatus, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, defaultScheduler, joinRelay, joinRoom, linkForUrl, resolveUrl, roomIdFrom, webSocketTransport };