@irtio/client 0.5.1 → 0.6.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,6 +1,8 @@
1
- import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
1
+ import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
2
+ import * as _irtio_protocol from '@irtio/protocol';
3
+ import { PresenceRecord, ProfileLedger, ProfileSnapshot } from '@irtio/protocol';
4
+ import * as MATTER from 'matter-js';
2
5
  import RAPIER from '@dimforge/rapier3d-compat';
3
- import { PresenceRecord } from '@irtio/protocol';
4
6
 
5
7
  /**
6
8
  * The client's state layer.
@@ -50,13 +52,16 @@ interface CorrectionOp {
50
52
  readonly snapped: boolean;
51
53
  }
52
54
  declare class ClientStore {
53
- readonly ext: AnySchema;
55
+ /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
56
+ ext: AnySchema;
54
57
  /** Reads the current client id — it is not known until the first `WELCOME`. */
55
58
  private readonly meOf;
56
59
  plain: PlainState;
57
60
  private tracked;
58
61
  private readonly descs;
59
62
  private readonly frozen;
63
+ /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
64
+ private readonly facades;
60
65
  /** The object handed out as `room.state`; identity survives a resync. */
61
66
  readonly view: AnyRecord$3;
62
67
  /** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
@@ -70,9 +75,24 @@ declare class ClientStore {
70
75
  * walks the window tick by tick starts from here, not from the newest local value.
71
76
  */
72
77
  private readonly baselineIntents;
73
- constructor(ext: AnySchema,
78
+ constructor(
79
+ /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
80
+ ext: AnySchema,
74
81
  /** Reads the current client id — it is not known until the first `WELCOME`. */
75
82
  meOf: () => string);
83
+ /**
84
+ * D50: rebuild every schema-derived handle in this store against `newExt`, keeping the objects
85
+ * user code holds. `view` keeps its identity (contractual, and pinned by test), and so does each
86
+ * collection facade behind it, because a game may well have hoisted `room.state.players` into a
87
+ * local.
88
+ *
89
+ * State itself is *not* carried across: `plain` is re-created empty under the new schema. That
90
+ * is not a loss, because a swap only ever happens immediately before the resync WELCOME that
91
+ * `rejoinAll` sends, and `loadSnapshot` re-seeds everything from it. Carrying the old records
92
+ * forward would be actively wrong — `normalizeRecord` rejects field names the old schema did
93
+ * not have, and a record built under the old descriptor is missing the new fields entirely.
94
+ */
95
+ swapSchema(newExt: AnySchema): void;
76
96
  loadSnapshot(bytes: Uint8Array): void;
77
97
  /**
78
98
  * Captures the field values of every pending owned write, keyed by collection then id then
@@ -87,7 +107,17 @@ declare class ClientStore {
87
107
  * whatever the resync snapshot happened to carry for that field.
88
108
  */
89
109
  applyPendingWrites(pending: Map<string, Map<string, AnyRecord$3>>): void;
90
- private buildView;
110
+ /**
111
+ * Defines one accessor per collection on `view`. Called once at construction and again on every
112
+ * schema swap, always against the SAME object, so `room.state`'s identity never changes.
113
+ *
114
+ * A collection that survives the swap keeps its facade and is retargeted at the new descriptor;
115
+ * a collection the new schema adds gets a fresh one; a collection that disappears has its
116
+ * accessor deleted. Only the first two can happen on an additive swap, but a `delete` is one
117
+ * line and a stale accessor reading a collection that no longer exists would throw from inside
118
+ * a getter, which is a miserable thing to debug.
119
+ */
120
+ private buildViewInto;
91
121
  /** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
92
122
  instance(desc: CollectionDesc, id: string): unknown;
93
123
  private freeze;
@@ -171,29 +201,30 @@ declare class ClientStore {
171
201
  }
172
202
 
173
203
  /**
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.
204
+ * The engine-neutral half of client-side physics prediction (D22 part 2, D21, D57): the loop, the
205
+ * render clock, the pose ring, the error smoothing, the cap, and the rebase replay. Everything an
206
+ * engine actually does sits behind {@link EngineAdapter}, and there is exactly one implementation
207
+ * of the loop for every engine irtio blesses.
176
208
  *
177
209
  * ## The contract
178
210
  *
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.
211
+ * The world is built from the **shared world-builder** the game passes to `joinRoom`: the same
212
+ * `setup` (static geometry), `bodies` (shape factories) and `intents` (intent → force, per step)
213
+ * functions the room's config uses, imported by both sides from one module (`irtio/world.ts` by
214
+ * convention — a convention, not a mechanism). The builder must be pure over synced inputs: no
215
+ * `Math.random()`, no clock, seed/level params read from synced state — or the two worlds are not
216
+ * the same world.
185
217
  *
186
218
  * ## The loop
187
219
  *
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
220
+ * The local world **free-runs** on a fixed-timestep accumulator driven by render reads: each step
221
+ * applies the owner's current intent values through the `intents` hook, then steps. When
190
222
  * authoritative body state arrives (a `CORRECT` for an owned body, a `DELTA` for a non-owned
191
223
  * predicted one), every predicted body is **rebased** to the server's values and the world
192
224
  * 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.
225
+ * buffered unjudged intent frames oldest-first (the write buffer holds exactly the server's input
226
+ * frames — only intents are writable on a physics entity). The lead is bounded by the shared
227
+ * resimulation depth (20, D19): beyond it the body snaps to authority and the snap is counted.
197
228
  *
198
229
  * ## What the renderer sees
199
230
  *
@@ -214,94 +245,68 @@ declare class ClientStore {
214
245
  * snapped back by the next correction. That is the documented constraint: predict both
215
246
  * (`predicted: true`, within the cap) or interpolate both.
216
247
  *
217
- * The engine itself is loaded lazily via dynamic `import()`, so a game with no physics option —
218
- * or a bundler that code-splitspays nothing for it.
248
+ * The engines themselves are loaded lazily, by an adapter module behind a dynamic `import()` from
249
+ * `session.ts`, so a game with no physics option or one that predicts with the other engine —
250
+ * pays nothing for the one it does not use.
219
251
  */
220
252
 
221
253
  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
254
  /**
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.
255
+ * One engine's body, opaque to this file. Created, read, stepped and removed only through the
256
+ * adapter the core stores the handle and hands it straight back.
239
257
  */
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'];
258
+ type EngineBody = object;
259
+ /** The schema's channel mapping for one collection, as `applyRecord` wants it. */
260
+ type BodyChannels = readonly (readonly [PhysicsBodyChannel, string])[];
246
261
  /**
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.
262
+ * Everything an engine does, and nothing else. One adapter per blessed engine
263
+ * (`physics.ts` for Rapier, `physics2d.ts` for matter-js); the adapter owns its module handle,
264
+ * its world, and the game's engine-shaped options (`gravity`, `setup`, `bodies`, `intents`), so
265
+ * no engine type reaches this file.
250
266
  */
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
- */
267
+ interface EngineAdapter {
268
+ /** The module specifier, for the "failed to load" line. */
269
+ readonly engineName: string;
270
+ /** The `joinRoom` option these hooks came from (`physics`, `physics2d`), for warnings. */
271
+ readonly optionName: string;
272
+ /** The game's explicit timestep in seconds, when it set one. */
273
+ readonly timestep: number | undefined;
274
+ /** Loads the engine, builds the local world and runs `setup`. Rejects if the load fails. */
275
+ start(timestepSeconds: number): Promise<void>;
276
+ /** Drops the world. The core clears its own bookkeeping. */
277
+ free(): void;
278
+ /** Did the game pass a body factory for this collection? */
279
+ hasFactory(collection: string): boolean;
280
+ /** Factory call plus every engine-specific warning about what it returned. */
281
+ createBody(desc: CollectionDesc, id: string, record: AnyRecord$2, warn: (key: string, message: string) => void): EngineBody | undefined;
282
+ removeBody(body: EngineBody): void;
283
+ /** One fixed-timestep step of the local world. */
284
+ step(): void;
285
+ /** Server record body state, in whatever order this engine's setters require. */
286
+ applyRecord(body: EngineBody, channels: BodyChannels, record: AnyRecord$2): void;
287
+ /** Body state → a 3D pose. A planar engine fills `z = 0` and a quaternion about Z. */
288
+ readPose(body: EngineBody, into: Pose): void;
289
+ /** The game's intent hook for an owned body, before one step. A no-op when it declared none. */
290
+ applyIntent(collection: string, body: EngineBody, instance: AnyRecord$2): void;
291
+ /**
292
+ * A per-step force applied to **every** local body of a collection, owned or not, after the
293
+ * intent pass. Present only on engines whose worlds do not apply gravity themselves: a matter2d
294
+ * room applies gravity per body from its own hooks, so a non-owned predicted crate would hang
295
+ * in the air through the whole lead without it. Rapier has no equivalent, and must not gain one.
296
+ */
297
+ settle?(collection: string, body: EngineBody, instance: AnyRecord$2): void;
298
+ /**
299
+ * The tolerance a velocity channel is compared against, given the position tolerance and the
300
+ * timestep. Rapier's velocities are per second (`epsilon / dt`); matter's are per step, and one
301
+ * step of a velocity error of `epsilon` is `epsilon` of position, so its answer is `epsilon`.
302
+ */
303
+ velocityTolerance(epsilon: number, timestepSeconds: number): number;
304
+ }
305
+ /** The engine-neutral tuning knobs; both engines' option objects carry these names. */
306
+ interface PredictorTuning {
271
307
  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
308
  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
309
  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
310
  readonly smoothingSnapUnits?: number;
306
311
  }
307
312
  /** Counters for the report and `room.prediction.stats`. */
@@ -339,20 +344,53 @@ interface PredictionStats {
339
344
  * applied tick judges a stamped write.
340
345
  */
341
346
  stampGap: number;
347
+ /**
348
+ * The same gap before the `[0, RESIM_DEPTH]` clamp, smoothed the same way (bugs.md #47). A
349
+ * negative reading is a stamp that *trails* the server's application: the lead under-estimated
350
+ * the transit and the clamp is hiding it from `stampGap`. Diagnostic; the replay never reads it.
351
+ */
352
+ stampGapRaw: number;
353
+ /** The stamp the newest judged `WRITE` carried, and the server tick it was applied at. */
354
+ lastStampTick: number;
355
+ lastAppliedTick: number;
342
356
  }
343
- declare class PhysicsPredictor {
357
+ /**
358
+ * One body's transform, as plain mutable objects: the ring below is overwritten every tick and
359
+ * must not allocate to do it.
360
+ */
361
+ interface Pose {
362
+ readonly t: Vec;
363
+ readonly r: Quat;
364
+ readonly v: Vec;
365
+ readonly w: Vec;
366
+ }
367
+ type Vec = {
368
+ x: number;
369
+ y: number;
370
+ z: number;
371
+ };
372
+ type Quat = {
373
+ x: number;
374
+ y: number;
375
+ z: number;
376
+ w: number;
377
+ };
378
+ declare class Predictor {
344
379
  private readonly store;
345
- private readonly options;
380
+ private readonly adapter;
381
+ private readonly tuning;
346
382
  private readonly meOf;
347
383
  private readonly rttOf;
348
384
  private readonly tickIntervalOf;
349
385
  private readonly log;
350
386
  readonly stats: PredictionStats;
351
- private rapier;
352
- private world;
387
+ /** True once the adapter's world exists. The `world !== undefined` of the one-engine version. */
388
+ private worldReady;
389
+ /** Seconds per step, fixed for the life of the world. */
390
+ private timestepSeconds;
353
391
  private readonly bodies;
354
- /** Physics-backed entity collections, in schema order. */
355
- private readonly collections;
392
+ /** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
393
+ private collections;
356
394
  private readonly warned;
357
395
  /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
358
396
  private readonly overCapHigh;
@@ -379,6 +417,8 @@ declare class PhysicsPredictor {
379
417
  private renderTick;
380
418
  /** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
381
419
  private readonly scratch;
420
+ /** Scratch for a direct body reading, so it never races `scratch`'s interpolated one. */
421
+ private readonly live;
382
422
  /** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
383
423
  private readonly qa;
384
424
  private readonly qb;
@@ -397,11 +437,35 @@ declare class PhysicsPredictor {
397
437
  private gapMeasured;
398
438
  /** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
399
439
  private gapSampledThrough;
440
+ /** The lead the last rebase re-stepped, so a change in it can be folded into `stampGap`. */
441
+ private lastLead;
400
442
  private readonly predictedTicks;
401
443
  private readonly history;
402
- /** History kept per body — comfortably past the resim depth. */
444
+ /**
445
+ * History kept per body: a correction for tick T is judged against the prediction recorded for
446
+ * T, and T is `lead` ticks behind the head when it arrives, so this has to clear `MAX_LEAD`
447
+ * with room for delivery jitter.
448
+ */
403
449
  private static readonly HISTORY_TICKS;
404
- constructor(ext: AnySchema, store: ClientStore, options: ClientPhysicsOptions, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
450
+ constructor(ext: AnySchema, store: ClientStore, adapter: EngineAdapter, tuning: PredictorTuning, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
451
+ /**
452
+ * D50: re-derive the predicted-collection list from a rebuilt schema.
453
+ *
454
+ * This is deliberately shallow, and it is safe to be shallow because of the scope rule the
455
+ * supervisor enforces: **any change touching a physics collection classifies breaking**
456
+ * (`packages/supervisor/src/schema-swap.ts`), so a swap that reaches this method is guaranteed
457
+ * to leave every physics collection structurally identical. What changes is descriptor
458
+ * *identity*, not content, and this brings the predictor's references back in line with the
459
+ * store's rather than leaving two equal-but-distinct descriptor trees in play.
460
+ *
461
+ * The live bodies are dropped instead of retargeted, for the same reason the render buffers
462
+ * are: `reset()` follows immediately, the resync WELCOME re-seeds authority, and a body carried
463
+ * across a schema boundary is exactly the kind of thing that would look fine and be wrong.
464
+ *
465
+ * When the physics scope rule is lifted, this is where the real work goes: rebuilding each
466
+ * `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
467
+ */
468
+ swapSchema(newExt: AnySchema): void;
405
469
  get epsilon(): number;
406
470
  /** `true` once the engine is loaded and the local world exists. */
407
471
  get ready(): boolean;
@@ -437,6 +501,12 @@ declare class PhysicsPredictor {
437
501
  * write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
438
502
  * authority is on the session's bare write counter rather than the server's tick stream, and
439
503
  * differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
504
+ *
505
+ * The floor is also load-bearing in a way that is not obvious, and `bugs.md` #45 is the
506
+ * measurement: a stamp can *trail* the server's application, when the lead over-estimates the
507
+ * transit, and correcting for that by letting the gap go negative delays every write's replay by
508
+ * the same amount. It buys an accurate stop and pays for it with a late start. The lever for
509
+ * that is the lead, not this.
440
510
  */
441
511
  noteWriteApplied(stampTick: number, appliedTick: number): void;
442
512
  /** Does the local world currently simulate `collection[id]`? */
@@ -444,8 +514,8 @@ declare class PhysicsPredictor {
444
514
  /**
445
515
  * Is a correction's every value within the suppression tolerance of the prediction it judges?
446
516
  * 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.
517
+ * channels against the adapter's per-engine velocity tolerance, because a velocity
518
+ * disagreement matters by what it moves in one tick.
449
519
  */
450
520
  withinEpsilon(desc: CollectionDesc, fields: readonly string[], patch: AnyRecord$2, predicted: AnyRecord$2): boolean;
451
521
  /** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
@@ -565,43 +635,32 @@ declare class PhysicsPredictor {
565
635
  private warnOverCap;
566
636
  private warnOnce;
567
637
  /**
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 |
638
+ * The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
639
+ * (bugs.md #47, Candidate A).
584
640
  *
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.
641
+ * The authority in hand left the server one one-way transit ago, so the server is at
642
+ * `authority + owd` right now, and an input flushed now reaches it another one-way transit
643
+ * later: the first tick it can take effect at is `authority + rtt`. The head has to be there
644
+ * for two reasons. The stamp is `head + 1`, and a stamp that names the tick the server will
645
+ * actually apply the write at is what lets the replay put a release where the server put it;
646
+ * with the head at half a round trip (server-now) the stamp trailed the application by
647
+ * `owd - 1` ticks, `stampGap`'s floor at zero discarded the sign, and every release was
648
+ * replayed a one-way transit late: the local body stopped, then followed authority forward for
649
+ * the rest of the round trip. And the intent the local body is simulating under is the intent
650
+ * the server will be simulating under at the same tick, which is the whole point of predicting.
588
651
  *
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.
652
+ * The price is the distance between the drawn body and the authority it is anchored to, which
653
+ * is the size of every misprediction the client has not been told about yet: a full round trip
654
+ * of motion instead of half. dive's lag budget states that distance against the round trip the
655
+ * test measures.
596
656
  *
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.
657
+ * Erring high is still the expensive direction, and the rounding is to nearest for that reason:
658
+ * a lead longer than the real round trip is a tick of motion the client draws on the assumption
659
+ * that the input it is holding will still be held when the server gets there, and on a release
660
+ * that assumption is wrong by construction (bug 1's "keeps moving after key up and then snaps
661
+ * back"). `stats.stampGap` is the running check: it reports how far ahead of the server's
662
+ * application the stamps land, and `stampGapRaw` the same before the clamp. Both should sit
663
+ * between 0 and 1.
605
664
  */
606
665
  private leadTicks;
607
666
  private timestepMs;
@@ -611,8 +670,8 @@ declare class PhysicsPredictor {
611
670
  * written, so plain state holds exactly what the server said), then re-step the world by the
612
671
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
613
672
  * 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`).
673
+ * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
674
+ * authority and counts (`stats.snaps`).
616
675
  */
617
676
  private rebase;
618
677
  /**
@@ -632,6 +691,11 @@ declare class PhysicsPredictor {
632
691
  * tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
633
692
  * current values (free-run: plain state carries the newest local intent writes, which is
634
693
  * correct there because free-run steps are the ticks *after* every buffered write).
694
+ *
695
+ * Then, on an engine that declares one, the `settle` pass: a per-step force over **every**
696
+ * local body of a collection, owned or not, run after the whole intent pass rather than
697
+ * interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
698
+ * whose world has none of its own.
635
699
  */
636
700
  private applyIntents;
637
701
  /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
@@ -639,6 +703,447 @@ declare class PhysicsPredictor {
639
703
  private isF32;
640
704
  }
641
705
 
706
+ /**
707
+ * The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
708
+ * option type, the lazy `matter-js` load, and the adapter that is the only place in the client
709
+ * where a matter.js type is touched.
710
+ *
711
+ * The loop is `predictor.ts`, shared byte for byte with the Rapier path. This file is the seam
712
+ * list, and it mirrors `packages/runtime/src/core/matter.ts` — the room's own matter world — the
713
+ * way `physics.ts` mirrors the Rapier runtime. Three things in it are load-bearing and none of
714
+ * them are visible from the loop:
715
+ *
716
+ * **The units are matter.js's, not translated.** `gravity` goes into `engine.gravity` verbatim
717
+ * (matter's own y is *down*), and a velocity is neither per second nor, strictly, per step:
718
+ * `Body.updateVelocities` normalises `body.velocity` against `Body._baseDelta`, a sixtieth of a
719
+ * second, whatever the engine's step is, and `Body.setVelocity` reads the same units back. The
720
+ * wire carries what the runtime writes, so the client reads and writes them unchanged, and
721
+ * `velocityTolerance` divides by `dt / baseDelta` rather than by `dt`. In a 60 Hz room those are
722
+ * the same thing and the tolerance is plain `epsilon`.
723
+ *
724
+ * **A rebase has to leave the body integrating from where it was put.** matter is a Verlet
725
+ * integrator: the next step's motion is `position - positionPrev`, so writing a position without
726
+ * moving `positionPrev` with it makes the whole jump this step's velocity. `Body.setPosition`
727
+ * shifts `positionPrev` by the same delta and `Body.setVelocity` re-derives it from the current
728
+ * position, which is why this uses the setters and follows `applyRecordToBody`'s order rather
729
+ * than assigning fields.
730
+ *
731
+ * **The timestep never varies.** `Body.update` scales a body's carried velocity by the ratio of
732
+ * consecutive deltas, so a constant delta makes that ratio 1. The same constant the room uses is
733
+ * the only safe one, which is why the predictor fixes it at start.
734
+ *
735
+ * ## `settle`
736
+ *
737
+ * A matter2d room applies gravity per body, from its own hooks, because `engine.gravity` is often
738
+ * zero (different bodies fall at different rates). An owned body gets that through `intents`. A
739
+ * non-owned predicted one — a crate the player stands on — has no intent hook and would hang in
740
+ * the air for the whole prediction lead, so `settle` runs the same per-step force over every local
741
+ * body of a collection, owned or not, after the intent pass. Rapier has no equivalent because
742
+ * engine gravity does that job there.
743
+ */
744
+
745
+ type ClientMatterModule = typeof MATTER;
746
+ type ClientMatterEngine = MATTER.Engine;
747
+ type ClientMatterBody = MATTER.Body;
748
+ type ClientMatterConstraint = MATTER.Constraint;
749
+ interface ClientVector2 {
750
+ readonly x: number;
751
+ readonly y: number;
752
+ }
753
+ /**
754
+ * What a client-side matter2d body factory returns — the same shape the room config's factories
755
+ * use. matter.js has no separate collider concept: a body *is* its geometry.
756
+ */
757
+ interface ClientBody2dSpec {
758
+ readonly body: ClientMatterBody;
759
+ /** Added to the local world with the body, and removed with it. */
760
+ readonly constraints?: readonly ClientMatterConstraint[];
761
+ }
762
+ /**
763
+ * Method-syntax members check bivariantly, so a builder's factory or hook written against its own
764
+ * instance type (`(body, player: Player) => …`) is accepted — the values really passed are the
765
+ * schema's records for that collection.
766
+ */
767
+ type ClientBody2dFactory = {
768
+ factory(matter: ClientMatterModule, instance: AnyRecord$2, id: string): ClientBody2dSpec;
769
+ }['factory'];
770
+ type ClientIntent2dHook = {
771
+ hook(body: ClientMatterBody, instance: AnyRecord$2, matter: ClientMatterModule, engine: ClientMatterEngine, timestep: number): void;
772
+ }['hook'];
773
+ /**
774
+ * `joinRoom({ physics2d })` — the client half of the shared world-builder contract for a matter2d
775
+ * room. A sibling of `physics`, not a variant inside it: passing both is an error at join, and the
776
+ * wire carries no engine name, so passing the *wrong* one is a game bug the client cannot detect.
777
+ * Every function here should be the very export the room config imports.
778
+ */
779
+ interface ClientPhysics2dOptions {
780
+ /** Must equal the room config's gravity, in matter's own convention (y is down). */
781
+ readonly gravity: ClientVector2;
782
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
783
+ readonly timestep?: number;
784
+ /** The shared static-geometry builder (the room's `physics.setup`). */
785
+ readonly setup?: (engine: ClientMatterEngine, matter: ClientMatterModule) => void;
786
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
787
+ readonly bodies?: Readonly<Record<string, ClientBody2dFactory>>;
788
+ /**
789
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
790
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
791
+ */
792
+ readonly intents?: Readonly<Record<string, ClientIntent2dHook>>;
793
+ /**
794
+ * A per-step force over **every** local body of a collection, owned or not, run after the whole
795
+ * intent pass. This is where a matter2d room's per-body gravity goes for the bodies nobody
796
+ * steers: without it a predicted crate stands still in mid-air for the length of the lead and
797
+ * is snapped back by every correction.
798
+ */
799
+ readonly settle?: Readonly<Record<string, ClientIntent2dHook>>;
800
+ /**
801
+ * D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
802
+ * are **absent from the local world** — they render by interpolation, but predicted bodies
803
+ * pass straight through them, so anything a predicted body stands on or is blocked by has to
804
+ * be under the cap (warned, counted as `stats.overCap`). Default 64.
805
+ */
806
+ readonly maxPredictedBodies?: number;
807
+ /**
808
+ * A body-field correction whose every value is within this tolerance of the local prediction
809
+ * is *suppressed*: authority still applies, but it is not a misprediction. Positions compare
810
+ * against `epsilon` world units; velocity channels compare against what one step of the error
811
+ * would actually move the body by, which on a 60 Hz room is also `epsilon` (matter stores a
812
+ * velocity per sixtieth of a second, not per second). Default 0.05.
813
+ */
814
+ readonly epsilon?: number;
815
+ /**
816
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it,
817
+ * as a half-life in milliseconds. `0` turns the smoothing off. Default 70. See
818
+ * `ClientPhysicsOptions.smoothingHalfLifeMs` for why this smooths the error and not the motion.
819
+ */
820
+ readonly smoothingHalfLifeMs?: number;
821
+ /**
822
+ * How far the drawn position may be held from the simulation while an offset eases away, in
823
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
824
+ */
825
+ readonly smoothingSnapUnits?: number;
826
+ }
827
+
828
+ /**
829
+ * The Rapier half of client-side physics prediction (D22 part 2, D21): `joinRoom({ physics })`'s
830
+ * option type, the lazy `@dimforge/rapier3d-compat` load, and the adapter that is the only place
831
+ * in the client where a Rapier type is touched.
832
+ *
833
+ * The loop itself — the free-run accumulator, the rebase replay, the pose ring, the render clock,
834
+ * the error smoothing and the D21 cap — lives in `predictor.ts` and knows about no engine at all.
835
+ * Read that file's header for what the predictor does and why; this one is the seam list:
836
+ * `loadEngine`, world construction and `setup`, the body factory and its warnings, `world.step()`,
837
+ * the record→body and body→pose mappings, the intent hook call, and the per-second velocity
838
+ * tolerance. `physics2d.ts` is the same list written against matter-js.
839
+ *
840
+ * The engine is loaded lazily via dynamic `import()`, so a game with no physics option — or one
841
+ * that predicts with matter2d — pays nothing for it.
842
+ */
843
+
844
+ type ClientRapierModule = typeof RAPIER;
845
+ type ClientRapierWorld = RAPIER.World;
846
+ type ClientRapierBody = RAPIER.RigidBody;
847
+ interface ClientVector3 {
848
+ readonly x: number;
849
+ readonly y: number;
850
+ readonly z: number;
851
+ }
852
+ /** What a client-side body factory returns — the same shape the room config's factories use. */
853
+ interface ClientBodySpec {
854
+ readonly body: RAPIER.RigidBodyDesc;
855
+ readonly colliders?: readonly RAPIER.ColliderDesc[];
856
+ }
857
+ /**
858
+ * Method-syntax members check bivariantly, so a builder's factory or intent hook written against
859
+ * its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
860
+ * the schema's records for that collection.
861
+ */
862
+ type ClientBodyFactory = {
863
+ factory(rapier: ClientRapierModule, instance: AnyRecord$2, id: string): ClientBodySpec;
864
+ }['factory'];
865
+ type ClientIntentHook = {
866
+ hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
867
+ }['hook'];
868
+ /**
869
+ * `joinRoom({ physics })` — the client half of the shared world-builder contract. Every function
870
+ * here should be the very export the room config imports, so "same code both sides" stays
871
+ * literally true.
872
+ */
873
+ interface ClientPhysicsOptions {
874
+ /** Must equal the room config's gravity (put it in the shared module). */
875
+ readonly gravity: ClientVector3;
876
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
877
+ readonly timestep?: number;
878
+ /** The shared static-geometry builder (the room's `physics.setup`). */
879
+ readonly setup?: (world: ClientRapierWorld, rapier: ClientRapierModule) => void;
880
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
881
+ readonly bodies?: Readonly<Record<string, ClientBodyFactory>>;
882
+ /**
883
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
884
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
885
+ */
886
+ readonly intents?: Readonly<Record<string, ClientIntentHook>>;
887
+ /**
888
+ * D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
889
+ * are **absent from the local world** — they render by interpolation, but predicted bodies
890
+ * pass straight through them, so anything a predicted body stands on or is blocked by has to
891
+ * be under the cap (warned, counted as `stats.overCap`). Default 64.
892
+ */
893
+ readonly maxPredictedBodies?: number;
894
+ /**
895
+ * A body-field correction whose every value is within this tolerance of the local prediction
896
+ * is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
897
+ * quiet. Positions compare against `epsilon` world units directly; velocity channels compare
898
+ * against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
899
+ * an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
900
+ */
901
+ readonly epsilon?: number;
902
+ /**
903
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it,
904
+ * as a half-life in milliseconds. `0` turns the smoothing off. Default 70.
905
+ *
906
+ * A rebase can move a body that has already been drawn — because the server disagreed, or
907
+ * because a newly-flushed intent changed what the last few ticks should have been. Handing
908
+ * that straight to the renderer is a step, and a step in the middle of steady motion is what
909
+ * a player calls a yank. Instead the jump is taken out of the drawn pose and put into a
910
+ * per-body offset that decays: the character keeps moving smoothly and arrives at the truth a
911
+ * moment later.
912
+ *
913
+ * This smooths the **error**, not the motion. A rate limiter on the drawn position — the
914
+ * obvious version, and the one a game writes for itself — cannot tell a correction from the
915
+ * character running, so it lags real movement too. Nothing here touches motion the simulation
916
+ * actually produced.
917
+ */
918
+ readonly smoothingHalfLifeMs?: number;
919
+ /**
920
+ * How far the drawn position may be held from the simulation while an offset eases away, in
921
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
922
+ *
923
+ * This is the teleport case: a respawn, an area change, a resync. Easing across one of those
924
+ * draws the body sliding through the level, which is worse than the step it avoids. It is the
925
+ * one number here that depends on how big a world unit is in your game.
926
+ */
927
+ readonly smoothingSnapUnits?: number;
928
+ }
929
+
930
+ /**
931
+ * D53 client side: the anonymous persistent identity, and the assertion exchange that keeps it
932
+ * out of the game.
933
+ *
934
+ * ## The one rule this module exists to enforce
935
+ *
936
+ * **The identity token never goes to a room socket.** It is stored in `localStorage`, sent to the
937
+ * control plane over HTTPS, and traded there for a short-lived, project-bound assertion. That
938
+ * assertion is the only thing that reaches the game.
939
+ *
940
+ * The reason matters more than the mechanism. Room code runs inside the game's own tenant, and a
941
+ * game you did not write is code you cannot vouch for — so anything the socket carries has to be
942
+ * worthless to a hostile game. An assertion is: it names one project (so it cannot be replayed
943
+ * against another game), it expires in minutes, and its subject is a per-project pseudonym, so
944
+ * two games holding assertions for the same person cannot tell that they do.
945
+ *
946
+ * ## What a game has to do
947
+ *
948
+ * ```ts
949
+ * const room = await joinRoom(schema, { identity: true });
950
+ * ```
951
+ *
952
+ * That is all. `joinRoom` mints an identity on first run, keeps it, exchanges it for an assertion
953
+ * before every HELLO — every reconnect included, because an assertion that outlives its exchange
954
+ * would be refused with `E_TOKEN_EXPIRED` — and `ctx.playerId` in room code is the stable
955
+ * `irt:<subject>` for that project.
956
+ *
957
+ * ## Storage, and what D61 changed about it
958
+ *
959
+ * `localStorage`, under ONE key per origin: `irtio.account`.
960
+ *
961
+ * It used to be one key per project (`irtio.identity.<project>`), so two games on one origin were
962
+ * two players. That was right while a credential WAS the player, and wrong the moment an account
963
+ * sat above it: the credential identifies a device, the account identifies the person, and what
964
+ * keeps two games from correlating a player is the per-project pseudonymous subject, which is
965
+ * derived at the control plane and owes nothing to how many keys a browser holds. Splitting the
966
+ * credential per project bought no privacy and cost the player an account per game.
967
+ *
968
+ * A value left under the old key is ADOPTED on first run and the old key is removed. A migrated
969
+ * identity is already an account (the migration backfilled one per identity with the identity's
970
+ * own id), so adopting it is a rename of a storage key and the player keeps every board position
971
+ * they had.
972
+ *
973
+ * The honest limits are unchanged and they are why linking exists: a player clearing site data is
974
+ * a new player, a private window is a new player, and a different browser is a different player
975
+ * until they link it. `linkCode()` and `redeemLinkCode()` are how a second device stops being a
976
+ * second player.
977
+ *
978
+ * Every storage access is wrapped: `localStorage` throws outright in some embedded contexts, and
979
+ * an identity that cannot be stored should degrade to an ordinary key join rather than take the
980
+ * game down.
981
+ */
982
+ /** The `localStorage` key: one credential per origin, whatever the project (D61). */
983
+ declare const ACCOUNT_STORAGE_KEY = "irtio.account";
984
+ /**
985
+ * The pre-D61 per-project key, kept only so a returning player's credential can be adopted once
986
+ * and then removed. Nothing writes this any more.
987
+ */
988
+ declare function identityStorageKey(project: string): string;
989
+ interface IdentityOptions {
990
+ /** The project key. Defaults to the schema's, exactly as `joinRoom` resolves it. */
991
+ readonly project: string;
992
+ /**
993
+ * The control plane's HTTPS origin. Defaults to `https://irt.io` — the apex where the control
994
+ * API lives (D-single-origin). A local `irtio dev` has no control plane at all, so a game
995
+ * testing identities against one must pass this.
996
+ */
997
+ readonly controlUrl?: string | undefined;
998
+ /** Injected in tests; `fetch` otherwise. */
999
+ readonly fetch?: typeof fetch | undefined;
1000
+ /**
1001
+ * Where the identity token is kept. Defaults to `localStorage`. A game with its own storage
1002
+ * (a native shell, an extension) can supply one; a game that passes `null` keeps the identity
1003
+ * only for the life of the page.
1004
+ */
1005
+ readonly storage?: IdentityStorage | null | undefined;
1006
+ }
1007
+ interface IdentityStorage {
1008
+ getItem(key: string): string | null;
1009
+ setItem(key: string, value: string): void;
1010
+ removeItem(key: string): void;
1011
+ }
1012
+ /** The default control origin: the apex that serves both the site and the control API. */
1013
+ declare const DEFAULT_CONTROL_URL = "https://irt.io";
1014
+ declare class IdentityError extends Error {
1015
+ readonly code: string;
1016
+ readonly name = "IdentityError";
1017
+ /**
1018
+ * How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
1019
+ * on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
1020
+ * tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
1021
+ */
1022
+ readonly retryAfterMs?: number | undefined;
1023
+ constructor(code: string, message: string, retryAfterMs?: number | undefined);
1024
+ }
1025
+ /** The distinct code a 429 from the control plane produces. */
1026
+ declare const E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
1027
+ /**
1028
+ * The longest `ensure()` will sit and wait before retrying a mint. Past this the wait is longer
1029
+ * than any game's patience, so the caller gets the named error with `retryAfterMs` attached and
1030
+ * decides for itself — sleeping for minutes inside a join is worse than saying why.
1031
+ */
1032
+ declare const MAX_IDENTITY_RETRY_WAIT_MS = 60000;
1033
+ /**
1034
+ * A player's identity for one project, and the assertion provider `joinRoom` hands the session.
1035
+ *
1036
+ * One instance per project per page. It caches the current assertion and re-exchanges when it is
1037
+ * close to expiring, so a reconnect storm does not become an exchange storm.
1038
+ */
1039
+ declare class Identity {
1040
+ private readonly project;
1041
+ private readonly controlUrl;
1042
+ private readonly fetchImpl;
1043
+ private readonly storage;
1044
+ private token;
1045
+ private accountId;
1046
+ private cached;
1047
+ private inFlight;
1048
+ constructor(options: IdentityOptions);
1049
+ /**
1050
+ * Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
1051
+ *
1052
+ * Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
1053
+ * whose id is the identity's own id, so the credential that was this player on this project is
1054
+ * already this player's account credential everywhere. Moving it to the origin-wide key is what
1055
+ * makes the same person on the same browser one player across the games on that origin.
1056
+ *
1057
+ * The old key is removed after a successful adoption. If the write fails (a storage that reads
1058
+ * but will not write), the old value is used anyway and adoption is retried next time, which is
1059
+ * the same degrade-quietly rule the rest of this module follows.
1060
+ */
1061
+ private load;
1062
+ /** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
1063
+ get stored(): string | undefined;
1064
+ /** The player id the room will see, once an assertion has been fetched. */
1065
+ get playerId(): string | undefined;
1066
+ /**
1067
+ * Mints an identity if this browser has none, and returns the token.
1068
+ *
1069
+ * A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
1070
+ * that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
1071
+ * a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
1072
+ * `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
1073
+ * "gone" (bug #36).
1074
+ */
1075
+ ensure(): Promise<string>;
1076
+ private mint;
1077
+ /**
1078
+ * A currently-valid assertion for this project, exchanging one if the cached one is gone or
1079
+ * within thirty seconds of expiry.
1080
+ *
1081
+ * The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
1082
+ * Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
1083
+ */
1084
+ assertion(now?: number): Promise<string>;
1085
+ private exchange;
1086
+ /** Drops the stored identity. The next `ensure()` mints a new player. */
1087
+ forget(): void;
1088
+ /**
1089
+ * Asks the control plane for a link code to read out on another device.
1090
+ *
1091
+ * The code is short and typable because a person carries it between two screens. Show it, do
1092
+ * not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
1093
+ * one thing about this that a player controls.
1094
+ */
1095
+ linkCode(): Promise<{
1096
+ code: string;
1097
+ expiresInMs: number;
1098
+ }>;
1099
+ /**
1100
+ * Redeems a code read off another device, and **replaces** this browser's credential with a new
1101
+ * one on that code's account.
1102
+ *
1103
+ * Two things happen in that order and both matter. The new credential is stored first, so a
1104
+ * failure between the two leaves the player linked rather than credential-less. Then whatever
1105
+ * this browser used to be is retired on the account it is leaving.
1106
+ *
1107
+ * **That retirement deletes the old account when this was its only device**, rather than
1108
+ * revoking the credential and walking away. Revoking the last device leaves an account nothing
1109
+ * can ever authenticate as, whose board rows and saved games are then unreachable by the player
1110
+ * and undeletable by anyone. An account with other devices only loses this one.
1111
+ *
1112
+ * So this call can destroy the progress held on THIS browser. Warn the player first; the docs
1113
+ * page has wording for it. The retirement is best-effort: if it fails the link still stands,
1114
+ * because failing a link that already worked is the worse direction.
1115
+ */
1116
+ redeemLinkCode(code: string): Promise<{
1117
+ account: string;
1118
+ }>;
1119
+ /** The devices on this account: ids and timestamps, plus which one this browser is. */
1120
+ devices(): Promise<{
1121
+ account: string;
1122
+ self: string;
1123
+ devices: {
1124
+ id: string;
1125
+ createdAt: string;
1126
+ lastSeen: string;
1127
+ }[];
1128
+ }>;
1129
+ /**
1130
+ * Revokes one device on this account. Revoking the device this browser IS leaves this instance
1131
+ * holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
1132
+ * mints a fresh player rather than looping on a refused exchange.
1133
+ */
1134
+ revokeDevice(deviceId: string): Promise<void>;
1135
+ /**
1136
+ * Deletes this account and everything keyed on it, in every project it played, and forgets the
1137
+ * credential. There is no undo and the control plane does not keep a copy.
1138
+ */
1139
+ deleteAccount(): Promise<void>;
1140
+ /** The account id, once anything has told us what it is. Opaque; never parse it. */
1141
+ get account(): string | undefined;
1142
+ /** One authenticated account-route call: credential in the header, project in the query. */
1143
+ private accountFetch;
1144
+ private post;
1145
+ }
1146
+
642
1147
  /**
643
1148
  * A client-side entity collection: the read API of `@irtio/schema`'s `ReadonlyCollection`, plus
644
1149
  * index sugar so `state.players[room.me]` reads the same as `state.players.get(room.me)`.
@@ -722,6 +1227,16 @@ interface RoomEvents {
722
1227
  status: Status;
723
1228
  error: RoomError;
724
1229
  correct: Correction;
1230
+ /**
1231
+ * Fires whenever the built-in presence collection changes (someone joins or leaves, or a
1232
+ * presence field updates), carrying the same array `room.clients` returns.
1233
+ */
1234
+ clients: readonly PresenceRecord[];
1235
+ /**
1236
+ * Fires after each `PONG` updates the smoothed round-trip time, about every `PING_INTERVAL_MS`
1237
+ * (2s), carrying the same value `room.rtt` returns.
1238
+ */
1239
+ rtt: number;
725
1240
  }
726
1241
  type Unsubscribe = () => void;
727
1242
  /**
@@ -797,12 +1312,32 @@ interface JoinOptions<S, Role extends string = string> {
797
1312
  * back a fresh token when the old one nears expiry. Omitted ⇒ the key-only join, unchanged.
798
1313
  */
799
1314
  readonly token?: string | (() => string | Promise<string>);
1315
+ /**
1316
+ * D53: join under an irtio anonymous persistent identity. `true` mints one on this browser's
1317
+ * first run, stores it, and exchanges it for a short-lived project-bound assertion before
1318
+ * every HELLO; an `Identity` instance is used as-is, so several joins can share one.
1319
+ *
1320
+ * `ctx.playerId` in room code becomes a stable `irt:<subject>` that survives reconnection,
1321
+ * hibernation and closing the tab — which is what a leaderboard keys on.
1322
+ *
1323
+ * The identity token itself never reaches the room. What rides the socket is the assertion:
1324
+ * bound to this one project, valid for minutes, and carrying a per-project pseudonym rather
1325
+ * than a platform id, so the game learns nothing about the player anywhere else. That is a
1326
+ * deliberate property and not an accident of the implementation — see `identity.ts`.
1327
+ *
1328
+ * Mutually exclusive with `token`: a join asserts one identity, and a client offering two
1329
+ * would be asking the server to pick.
1330
+ */
1331
+ readonly identity?: boolean | Identity;
1332
+ /** D53: the control plane origin the identity exchange talks to. Defaults to https://irt.io;
1333
+ * a local `irtio dev` has no control plane, so testing identities locally needs this. */
1334
+ readonly controlUrl?: string;
800
1335
  readonly onStatus?: (status: Status) => void;
801
1336
  /** Hard cap on the owned-write flush window, in ms. Default 50. */
802
1337
  readonly writeIntervalMs?: number;
803
1338
  /**
804
1339
  * How far behind arrival `room.render` draws non-owned entities, in ms (D20). Default:
805
- * `2 × tickIntervalMs` (the room's tick interval, learned from `WELCOME`), floored at 50 ms.
1340
+ * `2000 / tickRate` (twice the room's tick interval, learned from `WELCOME`), floored at 50 ms.
806
1341
  */
807
1342
  readonly interpDelayMs?: number;
808
1343
  /**
@@ -814,12 +1349,25 @@ interface JoinOptions<S, Role extends string = string> {
814
1349
  * code-splits keeps Rapier out of the critical path entirely.
815
1350
  */
816
1351
  readonly physics?: ClientPhysicsOptions;
1352
+ /**
1353
+ * D57: the same contract for a matter2d room — `setup`, `bodies`, `intents`, plus `settle`,
1354
+ * the per-step pass over every local body of a collection that gives a crate nobody owns its
1355
+ * gravity. A sibling of `physics` rather than a variant inside it, because the two engines
1356
+ * take different functions; passing both is an error at join. The wire carries no engine
1357
+ * name, so passing the one that does not match the room is a game bug the client cannot see.
1358
+ */
1359
+ readonly physics2d?: ClientPhysics2dOptions;
817
1360
  /** @internal */
818
1361
  readonly transport?: Transport;
819
1362
  /** @internal */
820
1363
  readonly scheduler?: Scheduler;
821
1364
  /** @internal */
822
1365
  readonly onFrame?: FrameHook;
1366
+ /**
1367
+ * D65: keep a bandwidth ledger for this session, readable as `room.profile`. Off by default and
1368
+ * free when off. A development and diagnostics surface, not something to ship enabled.
1369
+ */
1370
+ readonly profile?: boolean;
823
1371
  }
824
1372
  /** `joinRelay` options: a relay room has no schema, so there is no state and no RPC. */
825
1373
  interface JoinRelayOptions {
@@ -842,6 +1390,20 @@ interface JoinRelayOptions {
842
1390
  type MessageTarget = 'all' | string | {
843
1391
  readonly role: string;
844
1392
  };
1393
+ /**
1394
+ * D65: the client's view of where its bytes went, by collection and field.
1395
+ *
1396
+ * A client cannot tell area-of-interest churn from a real spawn — after the encode the two are
1397
+ * the same bytes, and only the server knows which ops it synthesised. So every add and remove in
1398
+ * a `spatial-grid` collection lands under `churn` here, and surfaces that show it say
1399
+ * `enter/leave (incl. spawns)` rather than pretending otherwise.
1400
+ */
1401
+ interface RoomProfile {
1402
+ /** Cumulative bytes since the socket opened, by row. */
1403
+ total(): _irtio_protocol.ProfileSnapshot;
1404
+ /** Bytes per second over a rolling window of about a second. */
1405
+ perSecond(): _irtio_protocol.ProfileSnapshot;
1406
+ }
845
1407
  /**
846
1408
  * Default D21 cap on non-owned predicted bodies per client.
847
1409
  *
@@ -897,6 +1459,11 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
897
1459
  readonly link: string;
898
1460
  /** The last server tick this client saw. */
899
1461
  readonly tick: number;
1462
+ /**
1463
+ * The room's configured client cap (`WELCOME`), or `0` when unknown (a relay-only server
1464
+ * predating this field).
1465
+ */
1466
+ readonly maxClients: number;
900
1467
  readonly status: Status;
901
1468
  /** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
902
1469
  readonly rtt: number;
@@ -920,6 +1487,12 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
920
1487
  * counters the bot runtime and the demos report.
921
1488
  */
922
1489
  readonly prediction?: PredictionStatus;
1490
+ /**
1491
+ * D65: this session's bandwidth ledger, present only when the join passed `profile: true`.
1492
+ * `total()` is cumulative since the socket opened; `perSecond()` is a rolling window of about a
1493
+ * second, advanced on the session's own clock each time it is read.
1494
+ */
1495
+ readonly profile?: RoomProfile;
923
1496
  readonly call: RoomCallProxy<S>;
924
1497
  /** Convenience alias for `room.call.requestOwnership`. */
925
1498
  requestOwnership(entity: string, id: string): Promise<boolean>;
@@ -934,6 +1507,11 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
934
1507
  interface RelayRoom {
935
1508
  readonly me: string;
936
1509
  readonly id: string;
1510
+ /**
1511
+ * The room's configured client cap (`WELCOME`), or `0` when unknown (a relay-only server
1512
+ * predating this field).
1513
+ */
1514
+ readonly maxClients: number;
937
1515
  readonly link: string;
938
1516
  readonly status: Status;
939
1517
  /** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
@@ -987,6 +1565,130 @@ declare function roomIdFrom(roomOrLink: string): string;
987
1565
  */
988
1566
  declare function linkForUrl(wsUrl: string, roomId: string): string;
989
1567
 
1568
+ /**
1569
+ * D52 client side: `matchRoom` — quick-match, then join.
1570
+ *
1571
+ * This is sugar over two things the SDK already does: one HTTP POST to the control plane, and
1572
+ * `joinRoom({ room })` with the code it answers. Nothing about the join differs from a friend
1573
+ * sharing a link, which is the property the whole design is built on — the room neither knows nor
1574
+ * cares that a matchmaker filled it.
1575
+ *
1576
+ * The wait is a long poll, so the request simply takes as long as the queue takes. A queue that
1577
+ * never fills answers "no match" at the deadline and `matchRoom` throws `E_NO_MATCH` rather than
1578
+ * silently dropping the player somewhere: a matchmaker that quietly degrades is one nobody can
1579
+ * debug, and a game that wants to show "still looking…" needs to be told.
1580
+ */
1581
+
1582
+ declare class MatchError extends Error {
1583
+ readonly code: string;
1584
+ readonly name = "MatchError";
1585
+ constructor(code: string, message: string);
1586
+ }
1587
+ interface MatchOptions {
1588
+ /** The queue to join. Omitted uses the project's `default` queue (party of two). */
1589
+ readonly queue?: string;
1590
+ /**
1591
+ * D63-g: a party code from `createParty`, so this player queues with their friends.
1592
+ *
1593
+ * Every member sends the same code with their own call. Nobody sends anybody else's identity:
1594
+ * a device credential is a secret that never leaves the browser that minted it, so a party is
1595
+ * a code you read out rather than a list you assemble.
1596
+ *
1597
+ * The queue fills only when the whole party is waiting, and the members land on consecutive
1598
+ * seats. A party bigger than the queue is refused with `E_PARTY_TOO_BIG` at the first call.
1599
+ */
1600
+ readonly party?: string;
1601
+ /**
1602
+ * How long to wait before giving up, in milliseconds. The control plane caps this at two
1603
+ * minutes and applies its own default (30 s) when omitted.
1604
+ */
1605
+ readonly timeoutMs?: number;
1606
+ /** The control plane origin. Defaults to `https://irt.io`. */
1607
+ readonly controlUrl?: string;
1608
+ /** Injected in tests. */
1609
+ readonly fetch?: typeof fetch;
1610
+ }
1611
+ /** What the queue answered. `seat` is a 0-based index into the party in arrival order. */
1612
+ interface MatchTicket {
1613
+ readonly room: string;
1614
+ readonly queue: string;
1615
+ /**
1616
+ * 0-based position in the party, in arrival order — **or `-1` for a backfill answer**, where
1617
+ * arrival order among a running room's players is not something the matchmaker knows.
1618
+ *
1619
+ * A game that uses `seat` as an index has to handle the `-1`. It is a value that cannot be
1620
+ * mistaken for a position, rather than an absent field, precisely so that a game which forgets
1621
+ * gets an obviously wrong answer instead of a plausible one.
1622
+ */
1623
+ readonly seat: number;
1624
+ readonly size: number;
1625
+ /**
1626
+ * True when this is a seat in a room that is already running rather than a fresh code.
1627
+ *
1628
+ * The room may fill before you get there — the matchmaker is working from an occupancy reading a
1629
+ * few seconds old — so a join off a backfill ticket can be refused with `E_ROOM_FULL`.
1630
+ * `matchRoom` handles that for you by queueing again, once.
1631
+ */
1632
+ readonly backfill: boolean;
1633
+ }
1634
+ /**
1635
+ * Queues for a game and resolves with the room code when the party fills.
1636
+ *
1637
+ * The identity, when one is supplied, is what gives the queue its one-ticket-per-player rule: a
1638
+ * player cannot fill a queue with themselves. Without one the queue falls back to limiting by
1639
+ * address, which is weaker (two players behind one NAT share a bucket) and is the honest reason
1640
+ * to adopt identities before shipping a quick-match button.
1641
+ */
1642
+ declare function findMatch(project: string, options?: MatchOptions & {
1643
+ readonly identity?: string;
1644
+ }): Promise<MatchTicket>;
1645
+ /** What `createParty` answers with. */
1646
+ interface PartyTicket {
1647
+ /** The code every member sends with their own `matchRoom` call. Readable out loud. */
1648
+ readonly party: string;
1649
+ /** How long the code stays good for. Ten minutes today. */
1650
+ readonly expiresInMs: number;
1651
+ }
1652
+ /**
1653
+ * Mints a party code so a group can queue together (D63-g).
1654
+ *
1655
+ * ```ts
1656
+ * const { party } = await createParty(schema.project, { size: 2 });
1657
+ * // read `party` out to your friend, then both of you:
1658
+ * const room = await matchRoom(schema, { queue: '2v2', party });
1659
+ * ```
1660
+ *
1661
+ * A code rather than a list of members, and the reason is worth knowing: an identity is a secret
1662
+ * that never leaves the browser that minted it, so there is no way for one player to hold
1663
+ * another's, and a ticket that claimed to speak for several players would be forgeable. Nobody
1664
+ * proves anything about anybody here — the matcher simply groups the tickets that carry the same
1665
+ * code — and the worst a guessed code can do is put a stranger in your game, which is what
1666
+ * quick-match does anyway.
1667
+ *
1668
+ * The code expires after `expiresInMs`. Expiry stops new members joining the party; members who
1669
+ * are already queued keep their tickets.
1670
+ */
1671
+ declare function createParty(project: string, options: {
1672
+ readonly size: number;
1673
+ } & Pick<MatchOptions, 'controlUrl' | 'fetch'>): Promise<PartyTicket>;
1674
+ /**
1675
+ * Quick-match, then join: the one call a quick-play button needs.
1676
+ *
1677
+ * ```ts
1678
+ * const room = await matchRoom(schema); // two strangers, one room, no code
1679
+ * const room = await matchRoom(schema, { queue: '1v1', identity: true });
1680
+ * ```
1681
+ *
1682
+ * With `identity: true` the player is minted an anonymous persistent identity (once, ever, stored
1683
+ * in `localStorage`), the queue enforces one ticket per identity, and the room sees a stable
1684
+ * `ctx.playerId` — which is what a leaderboard needs to key on.
1685
+ */
1686
+ declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S> & string>(schema: S, options?: MatchOptions & JoinOptions<S, Role> & {
1687
+ role?: RoleOf<S> & string;
1688
+ } & {
1689
+ readonly identity?: boolean;
1690
+ }): Promise<Room<S, Role>>;
1691
+
990
1692
  /**
991
1693
  * The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
992
1694
  * `WRITE` per rendered frame, which is what a game loop produces); everywhere else — and as a
@@ -1024,32 +1726,83 @@ declare const webSocketTransport: Transport;
1024
1726
 
1025
1727
  type AnyRecord$1 = Record<string, unknown>;
1026
1728
  declare class RenderStore {
1027
- private readonly ext;
1729
+ /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
1730
+ private ext;
1028
1731
  private readonly store;
1029
1732
  private readonly meOf;
1030
1733
  private readonly now;
1031
1734
  private readonly delayMs;
1735
+ /** The room's tick interval from `WELCOME`; 0 means unknown (pre-week-8 server). */
1736
+ private readonly intervalMs;
1032
1737
  /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
1033
1738
  private readonly buffers;
1034
1739
  private readonly descs;
1740
+ /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
1741
+ private readonly facades;
1035
1742
  /** The object handed out as `room.render`; identity survives a resync. */
1036
1743
  readonly view: AnyRecord$1;
1037
1744
  /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
1038
1745
  starved: number;
1039
1746
  /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
1040
1747
  private predictor;
1041
- constructor(ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number);
1748
+ /**
1749
+ * The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
1750
+ * is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
1751
+ */
1752
+ private base;
1753
+ /** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
1754
+ private baseAt;
1755
+ constructor(
1756
+ /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
1757
+ ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number,
1758
+ /** The room's tick interval from `WELCOME`; 0 means unknown (pre-week-8 server). */
1759
+ intervalMs: () => number);
1760
+ /**
1761
+ * D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
1762
+ * it, exactly as `ClientStore.swapSchema` does.
1763
+ *
1764
+ * Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
1765
+ * out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
1766
+ * keyframe against a new one under the new field list is precisely the silent misread this
1767
+ * whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
1768
+ * re-seeds one keyframe per entity, which is the same state the buffers would have started
1769
+ * from on a reconnect.
1770
+ */
1771
+ swapSchema(newExt: AnySchema): void;
1042
1772
  /**
1043
1773
  * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
1044
1774
  * the world is visible immediately (the join snapshot is the render baseline, not a change to
1045
1775
  * ease towards).
1046
1776
  */
1047
1777
  seedSnapshot(): void;
1048
- /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
1778
+ /**
1779
+ * Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
1780
+ * and the caller must fall back to arrival stamping.
1781
+ *
1782
+ * The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
1783
+ * delayed delta seen so far is the best evidence of where the server's tick clock sits, and
1784
+ * every later delta is that plus its own queueing delay. So a better sample is taken
1785
+ * immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
1786
+ * render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
1787
+ * clock rather than a late frame and snaps.
1788
+ */
1789
+ private stampFor;
1790
+ private snapMs;
1791
+ /**
1792
+ * Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
1793
+ * burstiness cannot modulate the drawn velocity. Call after apply.
1794
+ */
1049
1795
  recordDelta(delta: Delta): void;
1796
+ /**
1797
+ * Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
1798
+ * depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
1799
+ * a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
1800
+ * appends; and an offset that just snapped backwards must not stamp behind what is buffered.
1801
+ */
1802
+ private pushFrame;
1050
1803
  private bufferFor;
1051
1804
  private renderTime;
1052
- attachPredictor(predictor: PhysicsPredictor): void;
1805
+ attachPredictor(predictor: Predictor): void;
1053
1806
  /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
1054
1807
  get(desc: CollectionDesc, id: string): unknown;
1055
1808
  /** Is `collection[id]` visible at the render clock? */
@@ -1061,7 +1814,8 @@ declare class RenderStore {
1061
1814
  /** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
1062
1815
  private prune;
1063
1816
  private gc;
1064
- private buildView;
1817
+ /** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
1818
+ private buildViewInto;
1065
1819
  }
1066
1820
 
1067
1821
  /**
@@ -1098,35 +1852,64 @@ interface SessionOptions {
1098
1852
  * resume that outlives its token would otherwise be refused with `E_TOKEN_EXPIRED`.
1099
1853
  */
1100
1854
  readonly token?: string | (() => string | Promise<string>) | undefined;
1855
+ /**
1856
+ * D53: a provider for the platform identity assertion this session presents at HELLO.
1857
+ *
1858
+ * Always a function, never a string, and that is the point: an assertion lives for minutes
1859
+ * and a session lives for as long as the player plays, so a reconnect an hour in has to be
1860
+ * able to fetch a fresh one. A fixed string would be a session that dies at its first long
1861
+ * disconnect with `E_TOKEN_EXPIRED`.
1862
+ */
1863
+ readonly assertion?: (() => string | Promise<string>) | undefined;
1101
1864
  readonly role?: string | undefined;
1102
1865
  readonly name?: string | undefined;
1103
1866
  readonly rpc?: Readonly<Record<string, ClientImpl>> | undefined;
1104
1867
  readonly writeIntervalMs?: number | undefined;
1105
- /** Render delay for `room.render` (D20). Default `max(50, 2 × tickIntervalMs)`. */
1868
+ /** Render delay for `room.render` (D20). Default `max(50, 2000 / tickRate)`. */
1106
1869
  readonly interpDelayMs?: number | undefined;
1107
1870
  /** The shared world-builder half the client predicts with (D22 part 2). */
1108
1871
  readonly physics?: ClientPhysicsOptions | undefined;
1872
+ /** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
1873
+ readonly physics2d?: ClientPhysics2dOptions | undefined;
1109
1874
  readonly transport?: Transport | undefined;
1110
1875
  readonly scheduler?: Scheduler | undefined;
1111
1876
  readonly onFrame?: FrameHook | undefined;
1877
+ /** D65: keep a bandwidth ledger for this session. */
1878
+ readonly profile?: boolean | undefined;
1112
1879
  readonly onStatus?: ((status: Status) => void) | undefined;
1113
1880
  /** `true` in a browser with no explicit `room` option: `?room=` gets written back. */
1114
1881
  readonly publishLocation: boolean;
1115
1882
  }
1116
1883
  declare class Session {
1117
1884
  private readonly options;
1118
- readonly ext: AnySchema;
1885
+ /**
1886
+ * D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
1887
+ * new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
1888
+ * single assignment below is what actually moves the session onto the new schema.
1889
+ */
1890
+ ext: AnySchema;
1119
1891
  readonly store: ClientStore;
1120
1892
  readonly render: RenderStore;
1121
- /** `true` when the join passed `physics` and the schema has body-backed collections. */
1893
+ /** `true` when the join passed an engine option and the schema has body-backed collections. */
1122
1894
  readonly predictionRequested: boolean;
1123
1895
  /**
1124
- * Present once `./physics.js` has loaded (dynamic import the predictor code, like the
1125
- * engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
1896
+ * Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
1897
+ * `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
1898
+ * engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
1899
+ * interpolation until then.
1900
+ */
1901
+ predictor: Predictor | undefined;
1902
+ /** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
1903
+ private rpcSchema;
1904
+ /**
1905
+ * The BUILDER schema this session is currently on: `options.schema` at construction, then
1906
+ * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
1907
+ * swapped session announces its *current* hash when it reconnects rather than the one it was
1908
+ * born with — otherwise a resume after a swap would be refused as a mismatch.
1126
1909
  */
1127
- predictor: PhysicsPredictor | undefined;
1128
- /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
1129
- private readonly rpcSchema;
1910
+ private schema;
1911
+ /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
1912
+ schemaSwaps: number;
1130
1913
  private readonly transport;
1131
1914
  private readonly scheduler;
1132
1915
  private readonly writeIntervalMs;
@@ -1144,7 +1927,10 @@ declare class Session {
1144
1927
  */
1145
1928
  writeTick: number;
1146
1929
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
1930
+ /** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
1147
1931
  tickIntervalMs: number;
1932
+ /** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
1933
+ maxClients: number;
1148
1934
  rtt: number;
1149
1935
  status: Status;
1150
1936
  private socket;
@@ -1169,6 +1955,20 @@ declare class Session {
1169
1955
  private readonly pending;
1170
1956
  private readonly listeners;
1171
1957
  private readonly messageListeners;
1958
+ /**
1959
+ * D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
1960
+ *
1961
+ * The isolation clause has a client half as well as a server half. The supervisor guarantees that
1962
+ * a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
1963
+ * *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
1964
+ * receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
1965
+ * the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
1966
+ * and no ordering between two callbacks to get wrong.
1967
+ *
1968
+ * Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
1969
+ * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
1970
+ */
1971
+ private readonly voiceListeners;
1172
1972
  private cancelFlush;
1173
1973
  private cancelPing;
1174
1974
  private cancelRetry;
@@ -1191,10 +1991,78 @@ declare class Session {
1191
1991
  private fatal;
1192
1992
  private onSocketClosed;
1193
1993
  private stopTimers;
1994
+ /**
1995
+ * D65: the session's bandwidth ledger, present only when the join asked for one. A second
1996
+ * consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
1997
+ * compose rather than compete.
1998
+ */
1999
+ readonly ledger: ProfileLedger | undefined;
2000
+ /** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
2001
+ private profileWindow;
2002
+ /**
2003
+ * D65: bytes per second over a rolling window of about a second, measured on the scheduler's
2004
+ * clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
2005
+ *
2006
+ * The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
2007
+ * one-second window, and a reader that never calls costs nothing. A read sooner than a second
2008
+ * after the last one is extrapolated from the partial window rather than returning zeros.
2009
+ */
2010
+ profilePerSecond(): ProfileSnapshot;
1194
2011
  private send;
1195
2012
  private onFrame;
1196
2013
  /** A frame we could not decode or apply. Reported, never fatal: the stream may recover. */
1197
2014
  private localError;
2015
+ /**
2016
+ * D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
2017
+ * the old worker has stopped sending and before the resync WELCOME, so this session can stay
2018
+ * open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
2019
+ *
2020
+ * A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
2021
+ * the session on the old schema with a resync WELCOME about to arrive under the new one, which
2022
+ * it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
2023
+ * it anyway under the wrong descriptor, and a misdecode is silent.
2024
+ */
2025
+ private onSchema;
2026
+ /**
2027
+ * The one entry point for a mid-session schema change. Internal: not part of the public client
2028
+ * API this release, and not called from anywhere but `onSchema`.
2029
+ *
2030
+ * **What is rebuilt.** Every schema-derived handle in the client, top down:
2031
+ *
2032
+ * - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
2033
+ * `decodeSnapshot` and rpc-id lookup reads through;
2034
+ * - `descsByName`, the correction path's collection index, invalidated so it re-derives;
2035
+ * - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
2036
+ * facades behind `room.state` — retargeted, not replaced;
2037
+ * - `RenderStore`: the same, plus every interpolation buffer;
2038
+ * - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
2039
+ *
2040
+ * **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
2041
+ * by the old descriptors and has no correct reading under the new ones. The resync WELCOME
2042
+ * arriving immediately after re-establishes all of it, which is what makes dropping cheap:
2043
+ *
2044
+ * - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
2045
+ * - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
2046
+ * resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
2047
+ * indexed against descriptors that no longer exist;
2048
+ * - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
2049
+ * pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
2050
+ * the WELCOME;
2051
+ * - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
2052
+ *
2053
+ * **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
2054
+ * captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
2055
+ * was produced by a room that had those params in hand. So a call issued before the swap keeps
2056
+ * decoding its reply under the schema it was issued with, and resolves normally afterwards. This
2057
+ * costs nothing to implement — the descriptor is already captured per call rather than looked up
2058
+ * at reply time — and it is the honest semantics: the call did happen, under the old contract.
2059
+ *
2060
+ * The narrow case this leaves is a REPLY whose *shape* changed additively between the two
2061
+ * schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
2062
+ * which is exactly what an appended field means. A breaking change to an RPC never reaches here:
2063
+ * it classifies breaking and the session is closed instead.
2064
+ */
2065
+ private swapSchema;
1198
2066
  private onWelcome;
1199
2067
  private onDelta;
1200
2068
  private onCorrect;
@@ -1226,6 +2094,18 @@ declare class Session {
1226
2094
  private rejectPending;
1227
2095
  message(target: MessageTarget, bytes: Uint8Array): void;
1228
2096
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
2097
+ /**
2098
+ * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
2099
+ * so it is not on the `Room` type and a game cannot call it.
2100
+ *
2101
+ * This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
2102
+ * purpose (that is the third of the three isolation mechanisms), and widening it so that this
2103
+ * method could share the mapping would delete the mechanism to save four lines. Building the
2104
+ * `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
2105
+ */
2106
+ sendVoice(bytes: Uint8Array): void;
2107
+ /** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
2108
+ onVoice(cb: (bytes: Uint8Array) => void): Unsubscribe;
1229
2109
  get clients(): readonly PresenceRecord[];
1230
2110
  get link(): string;
1231
2111
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
@@ -1233,6 +2113,138 @@ declare class Session {
1233
2113
  private setStatus;
1234
2114
  }
1235
2115
 
2116
+ /**
2117
+ * D51: `joinVoice` — the client half of on-box voice.
2118
+ *
2119
+ * ## Why a free function and not `room.voice`
2120
+ *
2121
+ * `joinVoice(room)` mirrors `joinRoom(schema)`: a free function you call when you want the thing,
2122
+ * rather than a property that exists on every `Room` whether or not the game has voice. Three
2123
+ * reasons, in order of how much they matter.
2124
+ *
2125
+ * 1. **It keeps `mediasoup-client` off the join path.** The device library and its RTP capability
2126
+ * machinery are loaded by the first `joinVoice` call, dynamically. A game that never calls it
2127
+ * never pays for it, in bundle size or in startup work.
2128
+ * 2. **Voice is per-participant, not per-room.** A room can be joined by a spectator, an NPC or a
2129
+ * headless bot, none of which have a microphone; making voice a room property would suggest a
2130
+ * lifetime it does not have. The handle returned here is the lifetime.
2131
+ * 3. **It keeps the isolation clause structural.** Everything voice needs from the session —
2132
+ * `sendVoice`, `onVoice` — lives behind `INTERNAL_SESSION` and is absent from the public `Room`
2133
+ * type. Hanging voice off `Room` would have meant putting at least one of them within a game's
2134
+ * reach.
2135
+ *
2136
+ * ## Positional audio is not in this release
2137
+ *
2138
+ * The `positional` option is accepted and *ignored*. It is declared here rather than omitted so
2139
+ * that a game which already passes it keeps compiling and keeps working (with flat audio) rather
2140
+ * than failing to build, and so that turning it on later is an implementation change rather than an
2141
+ * API change. Passing `true` logs one warning and does nothing spatial: no panner, no listener
2142
+ * orientation, no distance model. See `mute` below for the one thing that *is* signalled.
2143
+ *
2144
+ * ## The handshake
2145
+ *
2146
+ * Fixed by the SFU's `dispatch`, and worth stating because the ordering is not obvious:
2147
+ *
2148
+ * join -> joined (both transports' parameters, the router's capabilities, current peers)
2149
+ * connect -> connected (per transport, fired lazily by mediasoup's own 'connect' event)
2150
+ * produce -> produced (our microphone; the SFU then tells peers via `peer-joined`)
2151
+ * consume -> consumed (per remote producer; the consumer starts PAUSED)
2152
+ * resume (no reply — the SFU acknowledges by letting packets flow)
2153
+ *
2154
+ * Consumers starting paused is mediasoup's documented sequence and the reason `resume` is a
2155
+ * separate message: the track is attached to its `<audio>` element before the first RTP packet
2156
+ * arrives, rather than racing it.
2157
+ *
2158
+ * Replies carry no request id, because the SFU's protocol has none. Correlation is therefore by
2159
+ * shape: `connected` by `transportId`, `consumed` by `producerId`, `joined` and `produced` by
2160
+ * being the only one outstanding. An `error` has nothing to correlate on at all, so it rejects
2161
+ * every outstanding waiter — which is what makes `E_VOICE_UNAVAILABLE` (a tenant with no SFU
2162
+ * configured, which is every box until O16 answers) come back as a rejected `joinVoice` rather
2163
+ * than a promise that hangs forever.
2164
+ */
2165
+
2166
+ /**
2167
+ * What is known about one other participant: what *they* did to their microphone, and what *you*
2168
+ * did to their playback on this machine. The two are deliberately separate fields rather than one
2169
+ * "silent" flag, because a UI that conflated them would tell a player they had muted somebody when
2170
+ * in fact that person had stopped talking.
2171
+ */
2172
+ interface VoicePeerState {
2173
+ /** The peer's own microphone mute, as last reported by the SFU. Not local. */
2174
+ readonly muted: boolean;
2175
+ /** This machine's local silencing of that peer. Never signalled, never seen by anyone else. */
2176
+ readonly mutedLocally: boolean;
2177
+ /** This machine's local playback volume for that peer, 0..1. */
2178
+ readonly volume: number;
2179
+ }
2180
+ interface VoiceHandle {
2181
+ /**
2182
+ * Mute or unmute the microphone. Signalled to the SFU rather than only stopped locally: the SFU
2183
+ * pauses the producer, which is what actually stops the bytes leaving the box, and an SFU that
2184
+ * merely saw a silent stream could not tell a muted participant from a quiet room.
2185
+ */
2186
+ mute(muted: boolean): void;
2187
+ readonly muted: boolean;
2188
+ /** The other participants currently in this room's voice call, by room client id. */
2189
+ readonly peers: readonly string[];
2190
+ /**
2191
+ * Local playback volume for one peer, 0..1 (clamped). Purely local: nothing is signalled, the
2192
+ * peer is not told, and every other participant hears them unchanged. Remembered for the life of
2193
+ * the handle, so setting it for a peer whose track has not arrived yet still takes effect when
2194
+ * it does.
2195
+ */
2196
+ setPeerVolume(peerId: string, volume: number): void;
2197
+ /**
2198
+ * Locally silence one peer. Distinct from the `peer-muted` event, which reports that peer's own
2199
+ * microphone: this one is yours, it is not signalled, and the two are reported separately by
2200
+ * `peerState`.
2201
+ */
2202
+ mutePeer(peerId: string, muted: boolean): void;
2203
+ /** The current state of one peer, or `undefined` if nobody by that id is in the call. */
2204
+ peerState(peerId: string): VoicePeerState | undefined;
2205
+ leave(): Promise<void>;
2206
+ on(event: 'peer-joined' | 'peer-left' | 'peer-muted' | 'peers-changed' | 'error', cb: (v: never) => void): () => void;
2207
+ }
2208
+ interface VoiceOptions {
2209
+ /**
2210
+ * **Not implemented in this release.** Accepted so that calling code compiles and runs, ignored
2211
+ * at runtime; passing `true` logs one warning and produces ordinary flat audio. Kept in the type
2212
+ * so that shipping it later is not a breaking change.
2213
+ */
2214
+ readonly positional?: boolean;
2215
+ }
2216
+ /**
2217
+ * The seam `@irtio/voice-ui` reads to draw a speaking indicator, and the only thing about a voice
2218
+ * call that is not on the public `VoiceHandle`.
2219
+ *
2220
+ * It is a *registered* symbol rather than a module-local one (unlike `INTERNAL_SESSION`, which
2221
+ * never leaves this package) because the reader is a different package: two copies of
2222
+ * `@irtio/client` in one bundle would otherwise mint two different symbols and the panel would
2223
+ * silently find nothing. Registered means "internal by convention, reachable across a package
2224
+ * boundary", which is exactly the contract here.
2225
+ *
2226
+ * It is not public API. The shape may change in any release; `@irtio/voice-ui` treats every field
2227
+ * as optional and degrades to no indicator when it is absent.
2228
+ */
2229
+ declare const INTERNAL_VOICE_TRACKS: unique symbol;
2230
+ /** What lives behind {@link INTERNAL_VOICE_TRACKS}. */
2231
+ interface VoiceTrackAccess {
2232
+ /** The remote track being played for one peer, if one has been attached. */
2233
+ peerTrack(peerId: string): MediaStreamTrack | undefined;
2234
+ /** This client's own microphone track, for a self speaking indicator. */
2235
+ micTrack(): MediaStreamTrack | undefined;
2236
+ }
2237
+ /**
2238
+ * Joins the voice call for a room this client is already in.
2239
+ *
2240
+ * Requires a microphone permission (the browser prompts) and a tenant with an SFU configured; a
2241
+ * tenant without one rejects with `E_VOICE_UNAVAILABLE` rather than hanging.
2242
+ *
2243
+ * `options.positional` is accepted and ignored — positional audio is not in this release. See the
2244
+ * module doc.
2245
+ */
2246
+ declare function joinVoice<S extends AnySchema, R extends string>(room: Room<S, R>, options?: VoiceOptions): Promise<VoiceHandle>;
2247
+
1236
2248
  /**
1237
2249
  * `@irtio/client` — the browser/Node SDK.
1238
2250
  *
@@ -1269,4 +2281,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
1269
2281
  */
1270
2282
  declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
1271
2283
 
1272
- 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, REGION_RE, 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 };
2284
+ export { ACCOUNT_STORAGE_KEY, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomProfile, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, createParty, defaultScheduler, findMatch, identityStorageKey, joinRelay, joinRoom, joinVoice, linkForUrl, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };