@irtio/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,15 +1,434 @@
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:
10
+ *
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).
7
20
  *
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.
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 is deliberately absent
199
+ *
200
+ * Non-predicted dynamic bodies do not exist in the local world, and neither do `predicted: true`
201
+ * instances that fall over `maxPredictedBodies`. A predicted body that collides with either on
202
+ * the server mispredicts here — it had nothing to collide with, so it passes through and is
203
+ * snapped back by the next correction. That is the documented constraint: predict both
204
+ * (`predicted: true`, within the cap) or interpolate both.
205
+ *
206
+ * The engine itself is loaded lazily via dynamic `import()`, so a game with no physics option —
207
+ * or a bundler that code-splits — pays nothing for it.
208
+ */
209
+
210
+ type AnyRecord$2 = Record<string, unknown>;
211
+ type ClientRapierModule = typeof RAPIER;
212
+ type ClientRapierWorld = RAPIER.World;
213
+ type ClientRapierBody = RAPIER.RigidBody;
214
+ interface ClientVector3 {
215
+ readonly x: number;
216
+ readonly y: number;
217
+ readonly z: number;
218
+ }
219
+ /** What a client-side body factory returns — the same shape the room config's factories use. */
220
+ interface ClientBodySpec {
221
+ readonly body: RAPIER.RigidBodyDesc;
222
+ readonly colliders?: readonly RAPIER.ColliderDesc[];
223
+ }
224
+ /**
225
+ * Method-syntax members check bivariantly, so a builder's factory or intent hook written against
226
+ * its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
227
+ * the schema's records for that collection.
228
+ */
229
+ type ClientBodyFactory = {
230
+ factory(rapier: ClientRapierModule, instance: AnyRecord$2, id: string): ClientBodySpec;
231
+ }['factory'];
232
+ type ClientIntentHook = {
233
+ hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
234
+ }['hook'];
235
+ /**
236
+ * `joinRoom({ physics })` — the client half of the shared world-builder contract. Every function
237
+ * here should be the very export the room config imports, so "same code both sides" stays
238
+ * literally true.
239
+ */
240
+ interface ClientPhysicsOptions {
241
+ /** Must equal the room config's gravity (put it in the shared module). */
242
+ readonly gravity: ClientVector3;
243
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
244
+ readonly timestep?: number;
245
+ /** The shared static-geometry builder (the room's `physics.setup`). */
246
+ readonly setup?: (world: ClientRapierWorld, rapier: ClientRapierModule) => void;
247
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
248
+ readonly bodies?: Readonly<Record<string, ClientBodyFactory>>;
249
+ /**
250
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
251
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
252
+ */
253
+ readonly intents?: Readonly<Record<string, ClientIntentHook>>;
254
+ /**
255
+ * D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
256
+ * are **absent from the local world** — they render by interpolation, but predicted bodies
257
+ * pass straight through them, so anything a predicted body stands on or is blocked by has to
258
+ * be under the cap (warned, counted as `stats.overCap`). Default 64.
259
+ */
260
+ readonly maxPredictedBodies?: number;
261
+ /**
262
+ * A body-field correction whose every value is within this tolerance of the local prediction
263
+ * is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
264
+ * quiet. Positions compare against `epsilon` world units directly; velocity channels compare
265
+ * against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
266
+ * an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
267
+ */
268
+ readonly epsilon?: number;
269
+ }
270
+ /** Counters for the report and `room.prediction.stats`. */
271
+ interface PredictionStats {
272
+ /** Steps taken free-running ahead of authority. */
273
+ freeSteps: number;
274
+ /** Steps taken re-simulating after a rebase. */
275
+ resimSteps: number;
276
+ /** Rebase passes (one per authoritative arrival batch). */
277
+ rebases: number;
278
+ /** Rebases whose lead outran the resim depth: the body snapped to authority. */
279
+ snaps: number;
280
+ /** Corrections whose values matched the local prediction within epsilon. */
281
+ suppressed: number;
282
+ /**
283
+ * Non-owned predicted instances currently over the cap. They render by interpolation but have
284
+ * no body in the local world, so predicted bodies pass through them: a non-zero value on a
285
+ * collection anything stands on is a gameplay bug, not a fidelity tradeoff.
286
+ */
287
+ overCap: number;
288
+ /** Microseconds spent in the last rebase's re-steps. */
289
+ lastResimMicros: number;
290
+ }
291
+ declare class PhysicsPredictor {
292
+ private readonly store;
293
+ private readonly options;
294
+ private readonly meOf;
295
+ private readonly rttOf;
296
+ private readonly tickIntervalOf;
297
+ private readonly log;
298
+ readonly stats: PredictionStats;
299
+ private rapier;
300
+ private world;
301
+ private readonly bodies;
302
+ /** Physics-backed entity collections, in schema order. */
303
+ private readonly collections;
304
+ private readonly warned;
305
+ /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
306
+ private readonly overCapHigh;
307
+ /** Authority arrived since the last frame: rebase before free-running. */
308
+ private authorityDirty;
309
+ private accumulatorMs;
310
+ private lastNow;
311
+ private lastFrameNow;
312
+ private failed;
313
+ /**
314
+ * Per owned body: the server tick its authoritative record is *from* (the tick of the last
315
+ * `CORRECT` that touched it), and the per-tick prediction history — after every local step,
316
+ * the body's channels are recorded under the tick that step predicted. A correction for tick T
317
+ * is then judged against the client's prediction **for tick T**, not against the current head
318
+ * of the simulation (which legitimately leads authority by the whole latency — comparing
319
+ * against it would report the lead as misprediction).
320
+ */
321
+ private readonly authorityTicks;
322
+ private readonly predictedTicks;
323
+ private readonly history;
324
+ /** History kept per body — comfortably past the resim depth. */
325
+ private static readonly HISTORY_TICKS;
326
+ constructor(ext: AnySchema, store: ClientStore, options: ClientPhysicsOptions, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
327
+ get epsilon(): number;
328
+ /** `true` once the engine is loaded and the local world exists. */
329
+ get ready(): boolean;
330
+ /**
331
+ * Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
332
+ * interpolation/authority — joining is never blocked on 2.9 MB of WASM.
333
+ */
334
+ start(): Promise<void>;
335
+ free(): void;
336
+ /** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
337
+ reset(): void;
338
+ /** Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one). */
339
+ noteAuthority(): void;
340
+ /** The session tells us which server tick one body's authoritative record is from. */
341
+ noteAuthorityTick(collection: string, id: string, tick: number): void;
342
+ /** Does the local world currently simulate `collection[id]`? */
343
+ has(collection: string, id: string): boolean;
344
+ /**
345
+ * Is a correction's every value within the suppression tolerance of the prediction it judges?
346
+ * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
347
+ * channels against `epsilon / timestep`, because a velocity disagreement matters by what it
348
+ * moves in one tick.
349
+ */
350
+ withinEpsilon(desc: CollectionDesc, fields: readonly string[], patch: AnyRecord$2, predicted: AnyRecord$2): boolean;
351
+ /** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
352
+ predictsCollection(name: string): boolean;
353
+ /**
354
+ * Advances the local world to `now`. Driven by render reads (one pass per timestamp), so a
355
+ * draw loop — or a bot's render sampling — is the clock; there is no timer.
356
+ */
357
+ frame(now: number): void;
358
+ /**
359
+ * The predicted record for `collection[id]`: the authoritative record (which already carries
360
+ * local intent writes — `track()` writes through to plain state) with the body-mapped channels
361
+ * replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
362
+ * reads is what the wire would carry.
363
+ */
364
+ read(desc: CollectionDesc, id: string, base: AnyRecord$2): AnyRecord$2;
365
+ /**
366
+ * The local world's current values for `fields` of one body — what a correction is judged
367
+ * against (`previous` on the `correct` event, and the epsilon-suppression comparison).
368
+ */
369
+ predictedValues(desc: CollectionDesc, id: string, fields: readonly string[], atTick?: number): AnyRecord$2 | undefined;
370
+ /**
371
+ * Mirrors the local world's bodies onto the instances this client predicts: every physics
372
+ * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
373
+ * (collection order, then insertion order — the same stated iteration guarantee the server
374
+ * follows, so which bodies fall over the cap is deterministic).
375
+ *
376
+ * Everything else — non-`predicted` collections, and `predicted` instances over the cap — gets
377
+ * no body and no collider here. Those instances still render (the interpolation path reads
378
+ * authoritative state directly), but nothing in the local world can touch them.
379
+ */
380
+ private reconcileBodies;
381
+ private createBody;
382
+ /**
383
+ * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
384
+ * world right now, so a predicted body walks through them. Warn on every new high-water mark
385
+ * per collection — a game that grows past the cap mid-session hears about it, and a count that
386
+ * oscillates around one level does not turn the console into a log.
387
+ */
388
+ private warnOverCap;
389
+ private warnOnce;
390
+ /**
391
+ * The client's lead over authority, in ticks: one-way latency plus one tick of margin.
392
+ * Measured (dive e2e, 2026-08-27): a full-round-trip lead was tried for the tick-aligned
393
+ * replay and made release transitions *worse* (-1.4 to -1.8 u worst backwards step vs -0.6 at
394
+ * half), because the extra lead grows the standing overshoot a rest-settling body has to give
395
+ * back. Half the trip plus margin is where the stamp clock and the correction stream meet.
396
+ */
397
+ private leadTicks;
398
+ private timestepMs;
399
+ /**
400
+ * Rebase + re-step: snap every predicted body to the authoritative record (server values —
401
+ * `DELTA` for non-owned bodies, `CORRECT` for owned ones; body fields are never client-
402
+ * written, so plain state holds exactly what the server said), then re-step the world by the
403
+ * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
404
+ * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
405
+ * judged write) before the first of them. Bounded by the shared resim depth: an outrun lead
406
+ * snaps to authority and counts (`stats.snaps`).
407
+ */
408
+ private rebase;
409
+ /**
410
+ * The tick a `WRITE` flushed right now should be stamped with: one past the newest predicted
411
+ * head across owned bodies — the first tick the new intent can affect. That puts the stamp on
412
+ * the same clock as `authorityTicks` and the resim window (the server's tick stream), which is
413
+ * what lets the rebase walk pending writes by tick instead of by array index. `undefined`
414
+ * until the engine runs or while nothing is owned; the session falls back to its counter.
415
+ */
416
+ stampTick(): number | undefined;
417
+ /** After every step: advance each owned body's prediction clock and remember its channels. */
418
+ private recordStep;
419
+ private freeRun;
420
+ /**
421
+ * Applies intents for every owned predicted body before one step: the intent in force at the
422
+ * tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
423
+ * current values (free-run: plain state carries the newest local intent writes, which is
424
+ * correct there because free-run steps are the ticks *after* every buffered write).
425
+ */
426
+ private applyIntents;
427
+ /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
428
+ private applyRecord;
429
+ private isF32;
430
+ }
431
+
13
432
  /**
14
433
  * A client-side entity collection: the read API of `@irtio/schema`'s `ReadonlyCollection`, plus
15
434
  * index sugar so `state.players[room.me]` reads the same as `state.players.get(room.me)`.
@@ -56,6 +475,11 @@ interface Correction {
56
475
  readonly fields: readonly string[];
57
476
  /** The server's values for those fields (already applied to `room.state`). */
58
477
  readonly patch: Readonly<Record<string, unknown>>;
478
+ /**
479
+ * The local (predicted) values of `fields` right before the snap. Misprediction magnitude is
480
+ * the numeric distance between `previous` and `patch`, per field.
481
+ */
482
+ readonly previous: Readonly<Record<string, unknown>>;
59
483
  readonly tick: number;
60
484
  /**
61
485
  * The client write tick the server judged (D19, week 8): every local write through it is
@@ -70,6 +494,19 @@ interface Correction {
70
494
  * writes) — everything it named snapped to the server's values, nothing was replayed.
71
495
  */
72
496
  readonly snapped: boolean;
497
+ /**
498
+ * D22: every corrected field is a simulation-owned body field this client does **not**
499
+ * predict — not a disagreement, just server-authoritative body state reaching its owner (once
500
+ * per tick, by design). When the client predicts the body, this is `false` and the correction
501
+ * is a real misprediction: `previous` holds the local world's predicted values.
502
+ */
503
+ readonly simulation: boolean;
504
+ /**
505
+ * D22 part 2: a predicted body's correction whose every value matched the local prediction
506
+ * within the epsilon — authority applied, but there was nothing to disagree about. Suppressed
507
+ * corrections keep a healthy predicted body's steady state quiet.
508
+ */
509
+ readonly suppressed: boolean;
73
510
  }
74
511
  interface RoomEvents {
75
512
  status: Status;
@@ -134,9 +571,30 @@ interface JoinOptions<S, Role extends string = string> {
134
571
  * (which is what `irtio dev` uses when there is no `irtio.json`).
135
572
  */
136
573
  readonly key?: string;
574
+ /**
575
+ * Week 13 (D27): a JWT asserting who this player is, minted by your server with the project's
576
+ * signing secret. `ctx.playerId` becomes the token's `sub`, and a `role` claim overrides
577
+ * `role` here. A function is called before every HELLO — reconnects included — so it can hand
578
+ * back a fresh token when the old one nears expiry. Omitted ⇒ the key-only join, unchanged.
579
+ */
580
+ readonly token?: string | (() => string | Promise<string>);
137
581
  readonly onStatus?: (status: Status) => void;
138
582
  /** Hard cap on the owned-write flush window, in ms. Default 50. */
139
583
  readonly writeIntervalMs?: number;
584
+ /**
585
+ * How far behind arrival `room.render` draws non-owned entities, in ms (D20). Default:
586
+ * `2 × tickIntervalMs` (the room's tick interval, learned from `WELCOME`), floored at 50 ms.
587
+ */
588
+ readonly interpDelayMs?: number;
589
+ /**
590
+ * D22 part 2: the client half of the shared world-builder contract — the same `setup`,
591
+ * `bodies` and `intents` functions the room's `physics` config imports (by convention from
592
+ * `irtio/world.ts`), plus `gravity`. With it, this client simulates its own bodies (and any
593
+ * `predicted: true` collection's, capped) ahead in a local world; without it, body-backed
594
+ * entities interpolate like everything else. The engine loads lazily — a bundler that
595
+ * code-splits keeps Rapier out of the critical path entirely.
596
+ */
597
+ readonly physics?: ClientPhysicsOptions;
140
598
  /** @internal */
141
599
  readonly transport?: Transport;
142
600
  /** @internal */
@@ -163,6 +621,27 @@ interface JoinRelayOptions {
163
621
  type MessageTarget = 'all' | string | {
164
622
  readonly role: string;
165
623
  };
624
+ /**
625
+ * Default D21 cap on non-owned predicted bodies per client.
626
+ *
627
+ * Sized against measurement, not caution: 41 dynamic bodies stacked in contact step in 0.005 to
628
+ * 0.007 ms (`games/dive/spike`, 2026-08-26), and a rebase re-steps the local world once per lead
629
+ * tick per authoritative tick — about 0.5 ms per second of wall clock at 30Hz with a 3-tick lead.
630
+ * Body count is not what makes prediction expensive, and the cost of a cap set too low is not
631
+ * saved CPU: over-cap instances are **absent from the local world** (see `reconcileBodies`), so
632
+ * a predicted body passes through them.
633
+ */
634
+ declare const MAX_PREDICTED_BODIES = 64;
635
+ /** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
636
+ declare const PREDICTION_EPSILON = 0.05;
637
+ /** `room.prediction` (D22 part 2). */
638
+ interface PredictionStatus {
639
+ /** The engine is loaded and the local world exists. */
640
+ readonly active: boolean;
641
+ /** Is `collection[id]` currently simulated in the local world? */
642
+ predicts(collection: string, id: string): boolean;
643
+ readonly stats: PredictionStats;
644
+ }
166
645
  /**
167
646
  * `room.call.<rpc>(params)` — every server RPC the builder declared, plus the one built-in.
168
647
  * `requestOwnership` takes the entity/id pair positionally and unwraps `{ granted }`.
@@ -184,8 +663,23 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
184
663
  /** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
185
664
  readonly rtt: number;
186
665
  readonly state: ClientState<S, Role>;
666
+ /**
667
+ * The render read path (D20): same shapes as `room.state`, but non-owned entities are
668
+ * interpolated at `now − interpDelayMs` (numeric fields lerp, everything else steps) while
669
+ * owned entities return the predicted local values. A draw loop opts in by one substitution —
670
+ * `room.state.players` → `room.render.players`. Tests and game logic keep reading
671
+ * `room.state`, which stays authoritative.
672
+ */
673
+ readonly render: ClientState<S, Role>;
187
674
  /** Built-in presence, ordered by join. */
188
675
  readonly clients: readonly PresenceRecord[];
676
+ /**
677
+ * Physics-prediction status, present when the join passed `physics` and the schema has
678
+ * body-backed collections (D22 part 2). `active` flips true once the engine loads; `predicts`
679
+ * says whether one instance currently reads from the local world; `stats` carries the
680
+ * counters the bot runtime and the demos report.
681
+ */
682
+ readonly prediction?: PredictionStatus;
189
683
  readonly call: RoomCallProxy<S>;
190
684
  /** Convenience alias for `room.call.requestOwnership`. */
191
685
  requestOwnership(entity: string, id: string): Promise<boolean>;
@@ -260,141 +754,58 @@ declare function defaultScheduler(): Scheduler;
260
754
  declare const webSocketTransport: Transport;
261
755
 
262
756
  /**
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:
757
+ * The interpolated render read path (D20, week 8): `room.render.<collection>` mirrors
758
+ * `room.state`'s shapes, but non-owned entities are rendered at `now − interpDelayMs`, lerping
759
+ * numeric fields between the two bracketing DELTAs and stepping everything else (strings, bools,
760
+ * enums, refs, lists). Owned entities return the predicted (local) values — the same object
761
+ * `room.state` hands out — so one read path serves the whole draw loop.
267
762
  *
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.
273
- *
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.
763
+ * `room.state` stays authoritative and untouched: tests, bots and existing code see exactly what
764
+ * they saw before. Entities appear and disappear at the buffered time; past the newest delta the
765
+ * value holds (no extrapolation) and the starvation is counted. A collection declared
766
+ * `interpolate: false` (and every singleton) reads straight through to the authoritative view.
281
767
  */
282
768
 
283
769
  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`. */
770
+ declare class RenderStore {
771
+ private readonly ext;
772
+ private readonly store;
310
773
  private readonly meOf;
311
- plain: PlainState;
312
- private tracked;
774
+ private readonly now;
775
+ private readonly delayMs;
776
+ /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
777
+ private readonly buffers;
313
778
  private readonly descs;
314
- private readonly frozen;
315
- /** The object handed out as `room.state`; identity survives a resync. */
779
+ /** The object handed out as `room.render`; identity survives a resync. */
316
780
  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>>;
781
+ /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
782
+ starved: number;
783
+ /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
784
+ private predictor;
785
+ constructor(ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number);
331
786
  /**
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.
787
+ * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
788
+ * the world is visible immediately (the join snapshot is the render baseline, not a change to
789
+ * ease towards).
336
790
  */
337
- applyPendingWrites(pending: Map<string, Map<string, AnyRecord$1>>): void;
791
+ seedSnapshot(): void;
792
+ /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
793
+ recordDelta(delta: Delta): void;
794
+ private bufferFor;
795
+ private renderTime;
796
+ attachPredictor(predictor: PhysicsPredictor): void;
797
+ /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
798
+ get(desc: CollectionDesc, id: string): unknown;
799
+ /** Is `collection[id]` visible at the render clock? */
800
+ has(desc: CollectionDesc, id: string): boolean;
801
+ /** The authoritative owner — ownership is fact, not a rendered value. */
802
+ ownerOf(desc: CollectionDesc, id: string): string | undefined;
803
+ /** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
804
+ ids(desc: CollectionDesc): IterableIterator<string>;
805
+ /** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
806
+ private prune;
807
+ private gc;
338
808
  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
809
  }
399
810
 
400
811
  /**
@@ -419,10 +830,21 @@ interface SessionOptions {
419
830
  readonly url: string;
420
831
  readonly roomId: string;
421
832
  readonly key: string;
833
+ /**
834
+ * Week 13 (D27): a JWT asserting who this player is, minted by YOUR server with the project's
835
+ * signing secret (`irtio keys jwt-secret`). A function is called before every HELLO — every
836
+ * reconnect included — so a fresh token can be supplied when the old one nears its `exp`; a
837
+ * resume that outlives its token would otherwise be refused with `E_TOKEN_EXPIRED`.
838
+ */
839
+ readonly token?: string | (() => string | Promise<string>) | undefined;
422
840
  readonly role?: string | undefined;
423
841
  readonly name?: string | undefined;
424
842
  readonly rpc?: Readonly<Record<string, ClientImpl>> | undefined;
425
843
  readonly writeIntervalMs?: number | undefined;
844
+ /** Render delay for `room.render` (D20). Default `max(50, 2 × tickIntervalMs)`. */
845
+ readonly interpDelayMs?: number | undefined;
846
+ /** The shared world-builder half the client predicts with (D22 part 2). */
847
+ readonly physics?: ClientPhysicsOptions | undefined;
426
848
  readonly transport?: Transport | undefined;
427
849
  readonly scheduler?: Scheduler | undefined;
428
850
  readonly onFrame?: FrameHook | undefined;
@@ -434,6 +856,14 @@ declare class Session {
434
856
  private readonly options;
435
857
  readonly ext: AnySchema;
436
858
  readonly store: ClientStore;
859
+ readonly render: RenderStore;
860
+ /** `true` when the join passed `physics` and the schema has body-backed collections. */
861
+ readonly predictionRequested: boolean;
862
+ /**
863
+ * Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
864
+ * engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
865
+ */
866
+ predictor: PhysicsPredictor | undefined;
437
867
  /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
438
868
  private readonly rpcSchema;
439
869
  private readonly transport;
@@ -445,9 +875,11 @@ declare class Session {
445
875
  roomId: string;
446
876
  tick: number;
447
877
  /**
448
- * The client-local write counter (D19): +1 per flushed `WRITE`, stamped in its delta header.
878
+ * The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
879
+ * prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
880
+ * what lets the rebase replay pending writes by tick — and otherwise a bare counter.
449
881
  * Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
450
- * for this client survives the grace window too.
882
+ * for this client survives the grace window too, and only ever moves forward.
451
883
  */
452
884
  writeTick: number;
453
885
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
@@ -455,6 +887,19 @@ declare class Session {
455
887
  rtt: number;
456
888
  status: Status;
457
889
  private socket;
890
+ /**
891
+ * Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
892
+ * `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
893
+ *
894
+ * Needed because a reconnecting session holds a socket that exists and is *not* sendable:
895
+ * `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
896
+ * `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
897
+ * test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
898
+ * connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
899
+ * where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
900
+ * window to be wide; it never opens against a local dev server.
901
+ */
902
+ private socketOpen;
458
903
  private resumeToken;
459
904
  private joined;
460
905
  private left;
@@ -470,6 +915,11 @@ declare class Session {
470
915
  private settleJoin;
471
916
  private failJoin;
472
917
  constructor(options: SessionOptions);
918
+ /**
919
+ * The `room.render` delay (D20): the explicit option, else twice the room's tick interval
920
+ * (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
921
+ */
922
+ get interpDelayMs(): number;
473
923
  /** Connects and resolves on the first `WELCOME`; a fatal `ERROR` before it rejects. */
474
924
  start(): Promise<Session>;
475
925
  private connect;
@@ -487,6 +937,10 @@ declare class Session {
487
937
  private onWelcome;
488
938
  private onDelta;
489
939
  private onCorrect;
940
+ private descsByName;
941
+ private descOfCollection;
942
+ /** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
943
+ private capturePredicted;
490
944
  private onError;
491
945
  private onPong;
492
946
  private onMsg;
@@ -497,6 +951,7 @@ declare class Session {
497
951
  * inside a window leaves as a single `WRITE`.
498
952
  */
499
953
  private armFlush;
954
+ private sendPing;
500
955
  private armPing;
501
956
  /** Sends every pending owned write now. */
502
957
  flush(): void;
@@ -553,4 +1008,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
553
1008
  */
554
1009
  declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
555
1010
 
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 };
1011
+ 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, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, defaultScheduler, joinRelay, joinRoom, linkForUrl, resolveUrl, roomIdFrom, webSocketTransport };