@irtio/client 0.5.2 → 0.7.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, MessageChannels, 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,96 @@ 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>>;
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;
260
283
  /**
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.
284
+ * D71: turn a body the factory just built into a **kinematic proxy**. It keeps the collider the
285
+ * factory gave it, it is never moved by gravity, by contact or by any other local force, and it
286
+ * goes only where {@link EngineAdapter.moveKinematic} puts it. Called once, immediately after
287
+ * `createBody`, and never on a body the local world is simulating.
263
288
  */
264
- readonly intents?: Readonly<Record<string, ClientIntentHook>>;
289
+ makeKinematic(body: EngineBody): void;
265
290
  /**
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
- */
291
+ * D71: put a kinematic proxy at `pose` for the next step, so that contacts see the velocity the
292
+ * move implies and a predicted body resting on it is carried.
293
+ *
294
+ * Called before every local step. Asking for the same pose twice moves nothing and implies no
295
+ * velocity — which is what "held, never extrapolated" means at the engine seam: a proxy driven
296
+ * through a forty-tick rebase stands still for all of it rather than running the same delta forty
297
+ * times.
298
+ */
299
+ moveKinematic(body: EngineBody, pose: Pose): void;
300
+ /** One fixed-timestep step of the local world. */
301
+ step(): void;
302
+ /** Server record → body state, in whatever order this engine's setters require. */
303
+ applyRecord(body: EngineBody, channels: BodyChannels, record: AnyRecord$2): void;
304
+ /** Body state → a 3D pose. A planar engine fills `z = 0` and a quaternion about Z. */
305
+ readPose(body: EngineBody, into: Pose): void;
306
+ /** The game's intent hook for an owned body, before one step. A no-op when it declared none. */
307
+ applyIntent(collection: string, body: EngineBody, instance: AnyRecord$2): void;
308
+ /**
309
+ * A per-step force applied to **every** local body of a collection, owned or not, after the
310
+ * intent pass. Present only on engines whose worlds do not apply gravity themselves: a matter2d
311
+ * room applies gravity per body from its own hooks, so a non-owned predicted crate would hang
312
+ * in the air through the whole lead without it. Rapier has no equivalent, and must not gain one.
313
+ */
314
+ settle?(collection: string, body: EngineBody, instance: AnyRecord$2): void;
315
+ /**
316
+ * The tolerance a velocity channel is compared against, given the position tolerance and the
317
+ * timestep. Rapier's velocities are per second (`epsilon / dt`); matter's are per step, and one
318
+ * step of a velocity error of `epsilon` is `epsilon` of position, so its answer is `epsilon`.
319
+ */
320
+ velocityTolerance(epsilon: number, timestepSeconds: number): number;
321
+ }
322
+ /**
323
+ * D71: where a kinematic proxy goes — the body-channel values the renderer is drawing for one
324
+ * instance at this instant, written into `into`. `false` when there is nothing drawn (the entity
325
+ * has not appeared at the render clock, or has left it).
326
+ *
327
+ * Backed by `RenderStore.drawn`, the D20 interpolation buffer: the authoritative pose at
328
+ * `interpDelayMs` behind arrival, **held** past the newest delta rather than extrapolated. The
329
+ * predictor never asks it for a time, so it can never be asked for the future.
330
+ */
331
+ type DrawnReader = (desc: CollectionDesc, id: string, into: Record<string, number>) => boolean;
332
+ /** The engine-neutral tuning knobs; both engines' option objects carry these names. */
333
+ interface PredictorTuning {
271
334
  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
- */
335
+ readonly maxProxyBodies?: number;
279
336
  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
337
  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
338
  readonly smoothingSnapUnits?: number;
306
339
  }
307
340
  /** Counters for the report and `room.prediction.stats`. */
@@ -317,11 +350,24 @@ interface PredictionStats {
317
350
  /** Corrections whose values matched the local prediction within epsilon. */
318
351
  suppressed: number;
319
352
  /**
320
- * Non-owned predicted instances currently over the cap. They render by interpolation but have
321
- * no body in the local world, so predicted bodies pass through them: a non-zero value on a
322
- * collection anything stands on is a gameplay bug, not a fidelity tradeoff.
353
+ * Non-owned predicted instances currently over `maxPredictedBodies`.
354
+ *
355
+ * Since D71 these are **proxied** rather than absent: each one still has a collider in the local
356
+ * world, moved to the pose the renderer draws, so a predicted body stands on it instead of
357
+ * falling through. What it no longer has is a simulation of its own — shove it and nothing
358
+ * happens locally until the server agrees. `stats.absent` is the number that means "you can fall
359
+ * through this"; this one means "you cannot push this".
323
360
  */
324
361
  overCap: number;
362
+ /** D71: kinematic proxies in the local world right now. */
363
+ proxies: number;
364
+ /**
365
+ * D71: non-owned instances with **no body in the local world at all** — over `maxProxyBodies`,
366
+ * opted out with `proxy: false`, or with no body factory on the client. Predicted bodies pass
367
+ * straight through these until the next correction snaps them back, so a non-zero value on a
368
+ * collection anything stands on is a gameplay bug rather than a fidelity tradeoff.
369
+ */
370
+ absent: number;
325
371
  /** Microseconds spent in the last rebase's re-steps. */
326
372
  lastResimMicros: number;
327
373
  /**
@@ -339,23 +385,64 @@ interface PredictionStats {
339
385
  * applied tick judges a stamped write.
340
386
  */
341
387
  stampGap: number;
388
+ /**
389
+ * The same gap before the `[0, RESIM_DEPTH]` clamp, smoothed the same way (bugs.md #47). A
390
+ * negative reading is a stamp that *trails* the server's application: the lead under-estimated
391
+ * the transit and the clamp is hiding it from `stampGap`. Diagnostic; the replay never reads it.
392
+ */
393
+ stampGapRaw: number;
394
+ /** The stamp the newest judged `WRITE` carried, and the server tick it was applied at. */
395
+ lastStampTick: number;
396
+ lastAppliedTick: number;
342
397
  }
343
- declare class PhysicsPredictor {
398
+ /**
399
+ * One body's transform, as plain mutable objects: the ring below is overwritten every tick and
400
+ * must not allocate to do it.
401
+ */
402
+ interface Pose {
403
+ readonly t: Vec;
404
+ readonly r: Quat;
405
+ readonly v: Vec;
406
+ readonly w: Vec;
407
+ }
408
+ type Vec = {
409
+ x: number;
410
+ y: number;
411
+ z: number;
412
+ };
413
+ type Quat = {
414
+ x: number;
415
+ y: number;
416
+ z: number;
417
+ w: number;
418
+ };
419
+ declare class Predictor {
344
420
  private readonly store;
345
- private readonly options;
421
+ private readonly adapter;
422
+ private readonly tuning;
346
423
  private readonly meOf;
347
424
  private readonly rttOf;
348
425
  private readonly tickIntervalOf;
349
426
  private readonly log;
350
427
  readonly stats: PredictionStats;
351
- private rapier;
352
- private world;
428
+ /** True once the adapter's world exists. The `world !== undefined` of the one-engine version. */
429
+ private worldReady;
430
+ /** Seconds per step, fixed for the life of the world. */
431
+ private timestepSeconds;
353
432
  private readonly bodies;
354
- /** Physics-backed entity collections, in schema order. */
355
- private readonly collections;
433
+ /** D71: kinematic proxies, keyed like `bodies`. The two maps are disjoint by construction. */
434
+ private readonly proxies;
435
+ /** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
436
+ private collections;
356
437
  private readonly warned;
357
438
  /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
358
439
  private readonly overCapHigh;
440
+ /** The same, for the proxy cap: `absent` is the count a game has to act on (D71-d). */
441
+ private readonly absentHigh;
442
+ /** D71: where a proxy goes. Wired by the session; see {@link DrawnReader}. */
443
+ private drawnReader;
444
+ /** `drawnReader`'s output buffer, reused: it is called once per proxy per frame. */
445
+ private readonly drawnChannels;
359
446
  /** Authority arrived since the last frame: rebase before free-running. */
360
447
  private authorityDirty;
361
448
  private accumulatorMs;
@@ -379,6 +466,8 @@ declare class PhysicsPredictor {
379
466
  private renderTick;
380
467
  /** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
381
468
  private readonly scratch;
469
+ /** Scratch for a direct body reading, so it never races `scratch`'s interpolated one. */
470
+ private readonly live;
382
471
  /** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
383
472
  private readonly qa;
384
473
  private readonly qb;
@@ -397,11 +486,40 @@ declare class PhysicsPredictor {
397
486
  private gapMeasured;
398
487
  /** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
399
488
  private gapSampledThrough;
489
+ /** The lead the last rebase re-stepped, so a change in it can be folded into `stampGap`. */
490
+ private lastLead;
400
491
  private readonly predictedTicks;
401
492
  private readonly history;
402
- /** History kept per body — comfortably past the resim depth. */
493
+ /**
494
+ * History kept per body: a correction for tick T is judged against the prediction recorded for
495
+ * T, and T is `lead` ticks behind the head when it arrives, so this has to clear `MAX_LEAD`
496
+ * with room for delivery jitter.
497
+ */
403
498
  private static readonly HISTORY_TICKS;
404
- constructor(ext: AnySchema, store: ClientStore, options: ClientPhysicsOptions, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
499
+ constructor(ext: AnySchema, store: ClientStore, adapter: EngineAdapter, tuning: PredictorTuning, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
500
+ /**
501
+ * D50: re-derive the predicted-collection list from a rebuilt schema.
502
+ *
503
+ * This is deliberately shallow, and it is safe to be shallow because of the scope rule the
504
+ * supervisor enforces: **any change touching a physics collection classifies breaking**
505
+ * (`packages/supervisor/src/schema-swap.ts`), so a swap that reaches this method is guaranteed
506
+ * to leave every physics collection structurally identical. What changes is descriptor
507
+ * *identity*, not content, and this brings the predictor's references back in line with the
508
+ * store's rather than leaving two equal-but-distinct descriptor trees in play.
509
+ *
510
+ * The live bodies are dropped instead of retargeted, for the same reason the render buffers
511
+ * are: `reset()` follows immediately, the resync WELCOME re-seeds authority, and a body carried
512
+ * across a schema boundary is exactly the kind of thing that would look fine and be wrong.
513
+ *
514
+ * When the physics scope rule is lifted, this is where the real work goes: rebuilding each
515
+ * `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
516
+ */
517
+ swapSchema(newExt: AnySchema): void;
518
+ /**
519
+ * D71: tell the predictor where its proxies go. The session calls this once, with a reader over
520
+ * the D20 interpolation buffer; without it a proxy holds the pose it was built at.
521
+ */
522
+ setDrawnReader(reader: DrawnReader): void;
405
523
  get epsilon(): number;
406
524
  /** `true` once the engine is loaded and the local world exists. */
407
525
  get ready(): boolean;
@@ -437,15 +555,32 @@ declare class PhysicsPredictor {
437
555
  * write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
438
556
  * authority is on the session's bare write counter rather than the server's tick stream, and
439
557
  * differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
558
+ *
559
+ * The floor is also load-bearing in a way that is not obvious, and `bugs.md` #45 is the
560
+ * measurement: a stamp can *trail* the server's application, when the lead over-estimates the
561
+ * transit, and correcting for that by letting the gap go negative delays every write's replay by
562
+ * the same amount. It buys an accurate stop and pays for it with a late start. The lever for
563
+ * that is the lead, not this.
440
564
  */
441
565
  noteWriteApplied(stampTick: number, appliedTick: number): void;
442
- /** Does the local world currently simulate `collection[id]`? */
566
+ /**
567
+ * Does the local world currently **simulate** `collection[id]`?
568
+ *
569
+ * Deliberately still false for a proxied instance (D71). Every caller of this asks it to decide
570
+ * whether the local world is the better answer than authority — the render read path, the
571
+ * correction classifier, `room.prediction.predicts` — and for a proxy it is not: a proxy is
572
+ * authority, one interpolation delay old, put into the world so that other bodies can touch it.
573
+ * Reading it back would be a round trip through the physics engine to learn what the render
574
+ * buffer already said. {@link proxied} is the question about proxies.
575
+ */
443
576
  has(collection: string, id: string): boolean;
577
+ /** D71: does `collection[id]` have a kinematic proxy in the local world right now? */
578
+ hasProxy(collection: string, id: string): boolean;
444
579
  /**
445
580
  * Is a correction's every value within the suppression tolerance of the prediction it judges?
446
581
  * 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.
582
+ * channels against the adapter's per-engine velocity tolerance, because a velocity
583
+ * disagreement matters by what it moves in one tick.
449
584
  */
450
585
  withinEpsilon(desc: CollectionDesc, fields: readonly string[], patch: AnyRecord$2, predicted: AnyRecord$2): boolean;
451
586
  /** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
@@ -545,63 +680,105 @@ declare class PhysicsPredictor {
545
680
  */
546
681
  predictedValues(desc: CollectionDesc, id: string, fields: readonly string[], atTick?: number): AnyRecord$2 | undefined;
547
682
  /**
548
- * Mirrors the local world's bodies onto the instances this client predicts: every physics
549
- * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
550
- * (collection order, then insertion order the same stated iteration guarantee the server
551
- * follows, so which bodies fall over the cap is deterministic).
683
+ * Mirrors the local world onto the instances this client can see: a **simulated** body for every
684
+ * physics instance it owns plus non-owned instances of `predicted: true` collections up to
685
+ * `maxPredictedBodies`, and a **kinematic proxy** (D71) for everything else that has a body
686
+ * factory and has not opted out with `proxy: false`, up to `maxProxyBodies`.
552
687
  *
553
- * Everything else non-`predicted` collections, and `predicted` instances over the cap gets
554
- * no body and no collider here. Those instances still render (the interpolation path reads
555
- * authoritative state directly), but nothing in the local world can touch them.
688
+ * Slot order is collection order then insertion order the same iteration guarantee the server
689
+ * states so which instances fall over either cap is deterministic. There is no relevance
690
+ * policy and no distance ordering: the ninth crate spawned is still the ninth crate, it is just
691
+ * now solid rather than missing.
692
+ *
693
+ * Three transitions happen here, and each is a removal before a creation so the world never holds
694
+ * two colliders for one id:
695
+ *
696
+ * - **Promotion.** A proxied instance of a predicted collection finds a free slot (a predicted
697
+ * body was removed, or the cap was raised). `createBody` applies the authority record, velocity
698
+ * channels included, so the body carries on rather than starting from rest.
699
+ * - **Demotion.** A simulated instance loses its slot to an earlier-collection newcomer. That is
700
+ * what this loop already did before proxies — it stopped marking the body live and the sweep
701
+ * below removed it — and now it becomes a proxy at the drawn pose in the same frame. It costs
702
+ * one visible pose step: the body was a lead ahead of authority and the proxy is an
703
+ * interpolation delay behind it.
704
+ * - **Removal.** An instance the server removed, or one that left this client's area of interest
705
+ * (D23), loses whichever of the two it had, the same frame.
706
+ *
707
+ * What is left over — over the proxy cap, `proxy: false`, or no factory — has no collider in the
708
+ * local world at all, is counted in `stats.absent`, and is the only case a predicted body still
709
+ * falls through.
556
710
  */
557
711
  private reconcileBodies;
712
+ /** Drops one simulated body and everything the loop keeps per body. Safe on a missing key. */
713
+ private removePredicted;
714
+ /** Drops one proxy. Safe on a missing key. */
715
+ private removeProxy;
716
+ /**
717
+ * D71: builds one kinematic proxy through the **same factory call** the simulated path uses, so
718
+ * the collider is the server's collider by construction rather than by a second description of
719
+ * it, then hands it to the adapter to be made kinematic.
720
+ *
721
+ * The authority record is applied first — a proxy appears where the server last said it was, not
722
+ * at the factory's origin — and the drawn pose takes over on the next `refreshProxyTargets`.
723
+ */
724
+ private createProxy;
725
+ /**
726
+ * Reads every proxy's drawn pose once per frame, and only once.
727
+ *
728
+ * Once, because a rebase re-steps the world up to `MAX_LEAD` times against a render clock that
729
+ * has not moved: asking again per step would return the same answer at a real cost. And once
730
+ * *because* of D71's rule — a proxy holds one pose for every re-step of a rebase. It cannot run
731
+ * ahead of the newest delta because the reader never extrapolates, and it cannot run ahead of the
732
+ * frame because it is only ever asked here.
733
+ *
734
+ * A proxy with nothing drawn (a delta gap, an entity not yet at the render clock) keeps the pose
735
+ * it had. That is the hold, and it is the difference between a crate that stays solid through a
736
+ * stall and a crate that slides off across the level.
737
+ */
738
+ private refreshProxyTargets;
739
+ /** Writes every proxy to its target pose. Runs immediately before each `adapter.step()`. */
740
+ private driveProxies;
558
741
  private createBody;
559
742
  /**
560
- * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
561
- * world right now, so a predicted body walks through them. Warn on every new high-water mark
562
- * per collection a game that grows past the cap mid-session hears about it, and a count that
563
- * oscillates around one level does not turn the console into a log.
743
+ * The two cap notices, both on a new high-water mark per collection: a game that grows past a cap
744
+ * mid-session hears about it, and a count that oscillates around one level does not turn the
745
+ * console into a log.
746
+ *
747
+ * They say different things now, and only the second is an emergency (D71). Over the *prediction*
748
+ * cap an instance is still solid — it has a proxy — it just is not simulated, so a shove does
749
+ * nothing locally until the server agrees. Over the *proxy* cap it is genuinely not there, which
750
+ * is the failure `bugs.md` #3 was about, and the message names both numbers so it is obvious
751
+ * which one to raise.
564
752
  */
565
- private warnOverCap;
753
+ private warnCaps;
566
754
  private warnOnce;
567
755
  /**
568
- * The client's lead over authority, in ticks: the one-way transit the authority in hand has
569
- * already spent in flight, rounded to the nearest tick. Nothing more.
570
- *
571
- * Every tick of lead beyond that transit is a tick of motion the client draws on the
572
- * assumption that the input it is holding will still be held when the server gets there. On a
573
- * release that assumption is wrong by construction, and the invented travel is handed back as
574
- * a backwards yank bug 1's "keeps moving after key up and then snaps back". It was
575
- * `ceil(owd) + 1`, which on a fast link is two whole ticks of margin over a transit of nearly
576
- * zero. Measured on the arena fixture at no injected latency (release overshoot of a 6 u/s
577
- * held-input character, `physics-predict.test.ts`):
578
- *
579
- * | lead | drawn overshoot past the server's stop | worst backwards step |
580
- * | --- | --- | --- |
581
- * | `ceil(owd) + 1` (was) | 0.585 u | -0.585 u |
582
- * | `max(1, round(owd))` (is) | 0.293 u | -0.292 u |
583
- * | `round(owd)`, floor 0 | 0.0008 u | -0.0008 u |
584
- *
585
- * One tick of running is 0.3 u there: the overshoot is the lead, in ticks, and nothing else.
586
- * On a link with no transit to cover, every tick of lead is a tick of guessing that the key is
587
- * still held.
588
- *
589
- * The floor of 1 is not margin, it is the smallest lead that is still prediction. At 0 the
590
- * rebase takes no steps: the local world is pinned to the authority it just received, which is
591
- * already a tick old, so the client responds to its own input only when the next `CORRECT`
592
- * arrives and every moving body reads as mispredicted by one tick of motion. Measured: the
593
- * bot suites' correction-storm invariant fires immediately (21 corrections/s per bot against a
594
- * threshold of 5 in `packages/cli/test/simulate-physics.test.ts`). One tick of speculation is
595
- * the price of predicting at all; two was the bug.
596
- *
597
- * Erring low is also the cheap direction — a lead shorter than the transit means authority
598
- * arrives slightly *ahead* of the prediction, and a correction that pulls the character the way
599
- * it is already going is the one nobody can see.
600
- *
601
- * Measured earlier the same day (dive e2e): a *full*-round-trip lead was worse again
602
- * (-1.4 to -1.8 u worst backwards step vs -0.6 at half), which is the same finding from the
603
- * other end. `stats.stampGap` is the running check on all of this — it reports how far ahead
604
- * of the server's application the stamps still land, and it should sit at 0.
756
+ * The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
757
+ * (bugs.md #47, Candidate A).
758
+ *
759
+ * The authority in hand left the server one one-way transit ago, so the server is at
760
+ * `authority + owd` right now, and an input flushed now reaches it another one-way transit
761
+ * later: the first tick it can take effect at is `authority + rtt`. The head has to be there
762
+ * for two reasons. The stamp is `head + 1`, and a stamp that names the tick the server will
763
+ * actually apply the write at is what lets the replay put a release where the server put it;
764
+ * with the head at half a round trip (server-now) the stamp trailed the application by
765
+ * `owd - 1` ticks, `stampGap`'s floor at zero discarded the sign, and every release was
766
+ * replayed a one-way transit late: the local body stopped, then followed authority forward for
767
+ * the rest of the round trip. And the intent the local body is simulating under is the intent
768
+ * the server will be simulating under at the same tick, which is the whole point of predicting.
769
+ *
770
+ * The price is the distance between the drawn body and the authority it is anchored to, which
771
+ * is the size of every misprediction the client has not been told about yet: a full round trip
772
+ * of motion instead of half. dive's lag budget states that distance against the round trip the
773
+ * test measures.
774
+ *
775
+ * Erring high is still the expensive direction, and the rounding is to nearest for that reason:
776
+ * a lead longer than the real round trip is a tick of motion the client draws on the assumption
777
+ * that the input it is holding will still be held when the server gets there, and on a release
778
+ * that assumption is wrong by construction (bug 1's "keeps moving after key up and then snaps
779
+ * back"). `stats.stampGap` is the running check: it reports how far ahead of the server's
780
+ * application the stamps land, and `stampGapRaw` the same before the clamp. Both should sit
781
+ * between 0 and 1.
605
782
  */
606
783
  private leadTicks;
607
784
  private timestepMs;
@@ -611,8 +788,8 @@ declare class PhysicsPredictor {
611
788
  * written, so plain state holds exactly what the server said), then re-step the world by the
612
789
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
613
790
  * 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`).
791
+ * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
792
+ * authority and counts (`stats.snaps`).
616
793
  */
617
794
  private rebase;
618
795
  /**
@@ -632,6 +809,16 @@ declare class PhysicsPredictor {
632
809
  * tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
633
810
  * current values (free-run: plain state carries the newest local intent writes, which is
634
811
  * correct there because free-run steps are the ticks *after* every buffered write).
812
+ *
813
+ * Then, on an engine that declares one, the `settle` pass: a per-step force over **every**
814
+ * local body of a collection, owned or not, run after the whole intent pass rather than
815
+ * interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
816
+ * whose world has none of its own.
817
+ *
818
+ * Both passes walk `this.bodies`, which is why neither ever reaches a proxy (D71): a proxy is not
819
+ * simulated, so an intent hook or a per-step gravity force on it would be a force on a body that
820
+ * cannot move, applied to a pose that is going to be overwritten before the next step anyway. The
821
+ * isolation is structural rather than a filter, which is the version that cannot rot.
635
822
  */
636
823
  private applyIntents;
637
824
  /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
@@ -639,6 +826,473 @@ declare class PhysicsPredictor {
639
826
  private isF32;
640
827
  }
641
828
 
829
+ /**
830
+ * The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
831
+ * option type, the lazy `matter-js` load, and the adapter that is the only place in the client
832
+ * where a matter.js type is touched.
833
+ *
834
+ * The loop is `predictor.ts`, shared byte for byte with the Rapier path. This file is the seam
835
+ * list, and it mirrors `packages/runtime/src/core/matter.ts` — the room's own matter world — the
836
+ * way `physics.ts` mirrors the Rapier runtime. Three things in it are load-bearing and none of
837
+ * them are visible from the loop:
838
+ *
839
+ * **The units are matter.js's, not translated.** `gravity` goes into `engine.gravity` verbatim
840
+ * (matter's own y is *down*), and a velocity is neither per second nor, strictly, per step:
841
+ * `Body.updateVelocities` normalises `body.velocity` against `Body._baseDelta`, a sixtieth of a
842
+ * second, whatever the engine's step is, and `Body.setVelocity` reads the same units back. The
843
+ * wire carries what the runtime writes, so the client reads and writes them unchanged, and
844
+ * `velocityTolerance` divides by `dt / baseDelta` rather than by `dt`. In a 60 Hz room those are
845
+ * the same thing and the tolerance is plain `epsilon`.
846
+ *
847
+ * **A rebase has to leave the body integrating from where it was put.** matter is a Verlet
848
+ * integrator: the next step's motion is `position - positionPrev`, so writing a position without
849
+ * moving `positionPrev` with it makes the whole jump this step's velocity. `Body.setPosition`
850
+ * shifts `positionPrev` by the same delta and `Body.setVelocity` re-derives it from the current
851
+ * position, which is why this uses the setters and follows `applyRecordToBody`'s order rather
852
+ * than assigning fields.
853
+ *
854
+ * **The timestep never varies.** `Body.update` scales a body's carried velocity by the ratio of
855
+ * consecutive deltas, so a constant delta makes that ratio 1. The same constant the room uses is
856
+ * the only safe one, which is why the predictor fixes it at start.
857
+ *
858
+ * ## `settle`
859
+ *
860
+ * A matter2d room applies gravity per body, from its own hooks, because `engine.gravity` is often
861
+ * zero (different bodies fall at different rates). An owned body gets that through `intents`. A
862
+ * non-owned predicted one — a crate the player stands on — has no intent hook and would hang in
863
+ * the air for the whole prediction lead, so `settle` runs the same per-step force over every local
864
+ * body of a collection, owned or not, after the intent pass. Rapier has no equivalent because
865
+ * engine gravity does that job there.
866
+ */
867
+
868
+ type ClientMatterModule = typeof MATTER;
869
+ type ClientMatterEngine = MATTER.Engine;
870
+ type ClientMatterBody = MATTER.Body;
871
+ type ClientMatterConstraint = MATTER.Constraint;
872
+ interface ClientVector2 {
873
+ readonly x: number;
874
+ readonly y: number;
875
+ }
876
+ /**
877
+ * What a client-side matter2d body factory returns — the same shape the room config's factories
878
+ * use. matter.js has no separate collider concept: a body *is* its geometry.
879
+ */
880
+ interface ClientBody2dSpec {
881
+ readonly body: ClientMatterBody;
882
+ /** Added to the local world with the body, and removed with it. */
883
+ readonly constraints?: readonly ClientMatterConstraint[];
884
+ }
885
+ /**
886
+ * Method-syntax members check bivariantly, so a builder's factory or hook written against its own
887
+ * instance type (`(body, player: Player) => …`) is accepted — the values really passed are the
888
+ * schema's records for that collection.
889
+ */
890
+ type ClientBody2dFactory = {
891
+ factory(matter: ClientMatterModule, instance: AnyRecord$2, id: string): ClientBody2dSpec;
892
+ }['factory'];
893
+ type ClientIntent2dHook = {
894
+ hook(body: ClientMatterBody, instance: AnyRecord$2, matter: ClientMatterModule, engine: ClientMatterEngine, timestep: number): void;
895
+ }['hook'];
896
+ /**
897
+ * `joinRoom({ physics2d })` — the client half of the shared world-builder contract for a matter2d
898
+ * room. A sibling of `physics`, not a variant inside it: passing both is an error at join, and the
899
+ * wire carries no engine name, so passing the *wrong* one is a game bug the client cannot detect.
900
+ * Every function here should be the very export the room config imports.
901
+ */
902
+ interface ClientPhysics2dOptions {
903
+ /** Must equal the room config's gravity, in matter's own convention (y is down). */
904
+ readonly gravity: ClientVector2;
905
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
906
+ readonly timestep?: number;
907
+ /** The shared static-geometry builder (the room's `physics.setup`). */
908
+ readonly setup?: (engine: ClientMatterEngine, matter: ClientMatterModule) => void;
909
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
910
+ readonly bodies?: Readonly<Record<string, ClientBody2dFactory>>;
911
+ /**
912
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
913
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
914
+ */
915
+ readonly intents?: Readonly<Record<string, ClientIntent2dHook>>;
916
+ /**
917
+ * A per-step force over **every** local body of a collection, owned or not, run after the whole
918
+ * intent pass. This is where a matter2d room's per-body gravity goes for the bodies nobody
919
+ * steers: without it a predicted crate stands still in mid-air for the length of the lead and
920
+ * is snapped back by every correction.
921
+ */
922
+ readonly settle?: Readonly<Record<string, ClientIntent2dHook>>;
923
+ /**
924
+ * D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
925
+ *
926
+ * Since D71 an over-cap instance is not gone from the local world — it gets a kinematic proxy
927
+ * (see `maxProxyBodies`), so a predicted body still stands on it and is still blocked by it.
928
+ * What it loses is being simulated: shove it and nothing happens locally until the server says
929
+ * it moved. Counted as `stats.overCap`.
930
+ */
931
+ readonly maxPredictedBodies?: number;
932
+ /**
933
+ * D71 cap: how many **kinematic proxies** this client keeps — colliders at the pose the renderer
934
+ * draws, for the instances it does not simulate. Default `MAX_PROXY_BODIES`. Past it, instances
935
+ * are **absent from the local world** and predicted bodies pass straight through them (warned,
936
+ * counted as `stats.absent`). See `ClientPhysicsOptions.maxProxyBodies` for why it is its own
937
+ * number rather than a share of the prediction cap.
938
+ */
939
+ readonly maxProxyBodies?: number;
940
+ /**
941
+ * A body-field correction whose every value is within this tolerance of the local prediction
942
+ * is *suppressed*: authority still applies, but it is not a misprediction. Positions compare
943
+ * against `epsilon` world units; velocity channels compare against what one step of the error
944
+ * would actually move the body by, which on a 60 Hz room is also `epsilon` (matter stores a
945
+ * velocity per sixtieth of a second, not per second). Default 0.05.
946
+ */
947
+ readonly epsilon?: number;
948
+ /**
949
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it,
950
+ * as a half-life in milliseconds. `0` turns the smoothing off. Default 70. See
951
+ * `ClientPhysicsOptions.smoothingHalfLifeMs` for why this smooths the error and not the motion.
952
+ */
953
+ readonly smoothingHalfLifeMs?: number;
954
+ /**
955
+ * How far the drawn position may be held from the simulation while an offset eases away, in
956
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
957
+ */
958
+ readonly smoothingSnapUnits?: number;
959
+ }
960
+
961
+ /**
962
+ * The Rapier half of client-side physics prediction (D22 part 2, D21): `joinRoom({ physics })`'s
963
+ * option type, the lazy `@dimforge/rapier3d-compat` load, and the adapter that is the only place
964
+ * in the client where a Rapier type is touched.
965
+ *
966
+ * The loop itself — the free-run accumulator, the rebase replay, the pose ring, the render clock,
967
+ * the error smoothing and the D21 cap — lives in `predictor.ts` and knows about no engine at all.
968
+ * Read that file's header for what the predictor does and why; this one is the seam list:
969
+ * `loadEngine`, world construction and `setup`, the body factory and its warnings, `world.step()`,
970
+ * the record→body and body→pose mappings, the intent hook call, and the per-second velocity
971
+ * tolerance. `physics2d.ts` is the same list written against matter-js.
972
+ *
973
+ * The engine is loaded lazily via dynamic `import()`, so a game with no physics option — or one
974
+ * that predicts with matter2d — pays nothing for it.
975
+ */
976
+
977
+ type ClientRapierModule = typeof RAPIER;
978
+ type ClientRapierWorld = RAPIER.World;
979
+ type ClientRapierBody = RAPIER.RigidBody;
980
+ interface ClientVector3 {
981
+ readonly x: number;
982
+ readonly y: number;
983
+ readonly z: number;
984
+ }
985
+ /** What a client-side body factory returns — the same shape the room config's factories use. */
986
+ interface ClientBodySpec {
987
+ readonly body: RAPIER.RigidBodyDesc;
988
+ readonly colliders?: readonly RAPIER.ColliderDesc[];
989
+ }
990
+ /**
991
+ * Method-syntax members check bivariantly, so a builder's factory or intent hook written against
992
+ * its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
993
+ * the schema's records for that collection.
994
+ */
995
+ type ClientBodyFactory = {
996
+ factory(rapier: ClientRapierModule, instance: AnyRecord$2, id: string): ClientBodySpec;
997
+ }['factory'];
998
+ type ClientIntentHook = {
999
+ hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
1000
+ }['hook'];
1001
+ /**
1002
+ * `joinRoom({ physics })` — the client half of the shared world-builder contract. Every function
1003
+ * here should be the very export the room config imports, so "same code both sides" stays
1004
+ * literally true.
1005
+ */
1006
+ interface ClientPhysicsOptions {
1007
+ /** Must equal the room config's gravity (put it in the shared module). */
1008
+ readonly gravity: ClientVector3;
1009
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
1010
+ readonly timestep?: number;
1011
+ /** The shared static-geometry builder (the room's `physics.setup`). */
1012
+ readonly setup?: (world: ClientRapierWorld, rapier: ClientRapierModule) => void;
1013
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
1014
+ readonly bodies?: Readonly<Record<string, ClientBodyFactory>>;
1015
+ /**
1016
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
1017
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
1018
+ */
1019
+ readonly intents?: Readonly<Record<string, ClientIntentHook>>;
1020
+ /**
1021
+ * D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
1022
+ *
1023
+ * Since D71 an over-cap instance is not gone from the local world — it gets a kinematic proxy
1024
+ * (see `maxProxyBodies`), so a predicted body still stands on it and is still blocked by it.
1025
+ * What it loses is being simulated: shove it and nothing happens locally until the server says
1026
+ * it moved. Raise this for the bodies players push; leave it for the ones they only stand on.
1027
+ * Counted as `stats.overCap`.
1028
+ */
1029
+ readonly maxPredictedBodies?: number;
1030
+ /**
1031
+ * D71 cap: how many **kinematic proxies** this client keeps — colliders at the pose the renderer
1032
+ * draws, for the instances it does not simulate (over `maxPredictedBodies`, or in a collection
1033
+ * that is not `predicted`). Default `MAX_PROXY_BODIES`.
1034
+ *
1035
+ * Its own number rather than a share of `maxPredictedBodies`, because a proxy is a different
1036
+ * cost: a pose write and a collider in the broad phase, not a body being integrated and solved.
1037
+ * Past this cap instances are **absent from the local world** — predicted bodies pass through
1038
+ * them until the next correction snaps them back (warned, counted as `stats.absent`). Turn
1039
+ * `proxy: false` on in the schema for the collections nothing collides with, so the budget goes
1040
+ * to the ones that matter.
1041
+ */
1042
+ readonly maxProxyBodies?: number;
1043
+ /**
1044
+ * A body-field correction whose every value is within this tolerance of the local prediction
1045
+ * is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
1046
+ * quiet. Positions compare against `epsilon` world units directly; velocity channels compare
1047
+ * against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
1048
+ * an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
1049
+ */
1050
+ readonly epsilon?: number;
1051
+ /**
1052
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it,
1053
+ * as a half-life in milliseconds. `0` turns the smoothing off. Default 70.
1054
+ *
1055
+ * A rebase can move a body that has already been drawn — because the server disagreed, or
1056
+ * because a newly-flushed intent changed what the last few ticks should have been. Handing
1057
+ * that straight to the renderer is a step, and a step in the middle of steady motion is what
1058
+ * a player calls a yank. Instead the jump is taken out of the drawn pose and put into a
1059
+ * per-body offset that decays: the character keeps moving smoothly and arrives at the truth a
1060
+ * moment later.
1061
+ *
1062
+ * This smooths the **error**, not the motion. A rate limiter on the drawn position — the
1063
+ * obvious version, and the one a game writes for itself — cannot tell a correction from the
1064
+ * character running, so it lags real movement too. Nothing here touches motion the simulation
1065
+ * actually produced.
1066
+ */
1067
+ readonly smoothingHalfLifeMs?: number;
1068
+ /**
1069
+ * How far the drawn position may be held from the simulation while an offset eases away, in
1070
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
1071
+ *
1072
+ * This is the teleport case: a respawn, an area change, a resync. Easing across one of those
1073
+ * draws the body sliding through the level, which is worse than the step it avoids. It is the
1074
+ * one number here that depends on how big a world unit is in your game.
1075
+ */
1076
+ readonly smoothingSnapUnits?: number;
1077
+ }
1078
+
1079
+ /**
1080
+ * D53 client side: the anonymous persistent identity, and the assertion exchange that keeps it
1081
+ * out of the game.
1082
+ *
1083
+ * ## The one rule this module exists to enforce
1084
+ *
1085
+ * **The identity token never goes to a room socket.** It is stored in `localStorage`, sent to the
1086
+ * control plane over HTTPS, and traded there for a short-lived, project-bound assertion. That
1087
+ * assertion is the only thing that reaches the game.
1088
+ *
1089
+ * The reason matters more than the mechanism. Room code runs inside the game's own tenant, and a
1090
+ * game you did not write is code you cannot vouch for — so anything the socket carries has to be
1091
+ * worthless to a hostile game. An assertion is: it names one project (so it cannot be replayed
1092
+ * against another game), it expires in minutes, and its subject is a per-project pseudonym, so
1093
+ * two games holding assertions for the same person cannot tell that they do.
1094
+ *
1095
+ * ## What a game has to do
1096
+ *
1097
+ * ```ts
1098
+ * const room = await joinRoom(schema, { identity: true });
1099
+ * ```
1100
+ *
1101
+ * That is all. `joinRoom` mints an identity on first run, keeps it, exchanges it for an assertion
1102
+ * before every HELLO — every reconnect included, because an assertion that outlives its exchange
1103
+ * would be refused with `E_TOKEN_EXPIRED` — and `ctx.playerId` in room code is the stable
1104
+ * `irt:<subject>` for that project.
1105
+ *
1106
+ * ## Storage, and what D61 changed about it
1107
+ *
1108
+ * `localStorage`, under ONE key per origin: `irtio.account`.
1109
+ *
1110
+ * It used to be one key per project (`irtio.identity.<project>`), so two games on one origin were
1111
+ * two players. That was right while a credential WAS the player, and wrong the moment an account
1112
+ * sat above it: the credential identifies a device, the account identifies the person, and what
1113
+ * keeps two games from correlating a player is the per-project pseudonymous subject, which is
1114
+ * derived at the control plane and owes nothing to how many keys a browser holds. Splitting the
1115
+ * credential per project bought no privacy and cost the player an account per game.
1116
+ *
1117
+ * A value left under the old key is ADOPTED on first run and the old key is removed. A migrated
1118
+ * identity is already an account (the migration backfilled one per identity with the identity's
1119
+ * own id), so adopting it is a rename of a storage key and the player keeps every board position
1120
+ * they had.
1121
+ *
1122
+ * The honest limits are unchanged and they are why linking exists: a player clearing site data is
1123
+ * a new player, a private window is a new player, and a different browser is a different player
1124
+ * until they link it. `linkCode()` and `redeemLinkCode()` are how a second device stops being a
1125
+ * second player.
1126
+ *
1127
+ * Every storage access is wrapped: `localStorage` throws outright in some embedded contexts, and
1128
+ * an identity that cannot be stored should degrade to an ordinary key join rather than take the
1129
+ * game down.
1130
+ */
1131
+ /** The `localStorage` key: one credential per origin, whatever the project (D61). */
1132
+ declare const ACCOUNT_STORAGE_KEY = "irtio.account";
1133
+ /**
1134
+ * The pre-D61 per-project key, kept only so a returning player's credential can be adopted once
1135
+ * and then removed. Nothing writes this any more.
1136
+ */
1137
+ declare function identityStorageKey(project: string): string;
1138
+ interface IdentityOptions {
1139
+ /** The project key. Defaults to the schema's, exactly as `joinRoom` resolves it. */
1140
+ readonly project: string;
1141
+ /**
1142
+ * The control plane's HTTPS origin. Defaults to `https://irt.io` — the apex where the control
1143
+ * API lives (D-single-origin). A local `irtio dev` has no control plane at all, so a game
1144
+ * testing identities against one must pass this.
1145
+ */
1146
+ readonly controlUrl?: string | undefined;
1147
+ /** Injected in tests; `fetch` otherwise. */
1148
+ readonly fetch?: typeof fetch | undefined;
1149
+ /**
1150
+ * Where the identity token is kept. Defaults to `localStorage`. A game with its own storage
1151
+ * (a native shell, an extension) can supply one; a game that passes `null` keeps the identity
1152
+ * only for the life of the page.
1153
+ */
1154
+ readonly storage?: IdentityStorage | null | undefined;
1155
+ }
1156
+ interface IdentityStorage {
1157
+ getItem(key: string): string | null;
1158
+ setItem(key: string, value: string): void;
1159
+ removeItem(key: string): void;
1160
+ }
1161
+ /** The default control origin: the apex that serves both the site and the control API. */
1162
+ declare const DEFAULT_CONTROL_URL = "https://irt.io";
1163
+ declare class IdentityError extends Error {
1164
+ readonly code: string;
1165
+ readonly name = "IdentityError";
1166
+ /**
1167
+ * How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
1168
+ * on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
1169
+ * tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
1170
+ */
1171
+ readonly retryAfterMs?: number | undefined;
1172
+ constructor(code: string, message: string, retryAfterMs?: number | undefined);
1173
+ }
1174
+ /** The distinct code a 429 from the control plane produces. */
1175
+ declare const E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
1176
+ /**
1177
+ * The longest `ensure()` will sit and wait before retrying a mint. Past this the wait is longer
1178
+ * than any game's patience, so the caller gets the named error with `retryAfterMs` attached and
1179
+ * decides for itself — sleeping for minutes inside a join is worse than saying why.
1180
+ */
1181
+ declare const MAX_IDENTITY_RETRY_WAIT_MS = 60000;
1182
+ /**
1183
+ * A player's identity for one project, and the assertion provider `joinRoom` hands the session.
1184
+ *
1185
+ * One instance per project per page. It caches the current assertion and re-exchanges when it is
1186
+ * close to expiring, so a reconnect storm does not become an exchange storm.
1187
+ */
1188
+ declare class Identity {
1189
+ private readonly project;
1190
+ private readonly controlUrl;
1191
+ private readonly fetchImpl;
1192
+ private readonly storage;
1193
+ private token;
1194
+ private accountId;
1195
+ private cached;
1196
+ private inFlight;
1197
+ constructor(options: IdentityOptions);
1198
+ /**
1199
+ * Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
1200
+ *
1201
+ * Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
1202
+ * whose id is the identity's own id, so the credential that was this player on this project is
1203
+ * already this player's account credential everywhere. Moving it to the origin-wide key is what
1204
+ * makes the same person on the same browser one player across the games on that origin.
1205
+ *
1206
+ * The old key is removed after a successful adoption. If the write fails (a storage that reads
1207
+ * but will not write), the old value is used anyway and adoption is retried next time, which is
1208
+ * the same degrade-quietly rule the rest of this module follows.
1209
+ */
1210
+ private load;
1211
+ /** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
1212
+ get stored(): string | undefined;
1213
+ /** The player id the room will see, once an assertion has been fetched. */
1214
+ get playerId(): string | undefined;
1215
+ /**
1216
+ * Mints an identity if this browser has none, and returns the token.
1217
+ *
1218
+ * A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
1219
+ * that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
1220
+ * a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
1221
+ * `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
1222
+ * "gone" (bug #36).
1223
+ */
1224
+ ensure(): Promise<string>;
1225
+ private mint;
1226
+ /**
1227
+ * A currently-valid assertion for this project, exchanging one if the cached one is gone or
1228
+ * within thirty seconds of expiry.
1229
+ *
1230
+ * The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
1231
+ * Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
1232
+ */
1233
+ assertion(now?: number): Promise<string>;
1234
+ private exchange;
1235
+ /** Drops the stored identity. The next `ensure()` mints a new player. */
1236
+ forget(): void;
1237
+ /**
1238
+ * Asks the control plane for a link code to read out on another device.
1239
+ *
1240
+ * The code is short and typable because a person carries it between two screens. Show it, do
1241
+ * not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
1242
+ * one thing about this that a player controls.
1243
+ */
1244
+ linkCode(): Promise<{
1245
+ code: string;
1246
+ expiresInMs: number;
1247
+ }>;
1248
+ /**
1249
+ * Redeems a code read off another device, and **replaces** this browser's credential with a new
1250
+ * one on that code's account.
1251
+ *
1252
+ * Two things happen in that order and both matter. The new credential is stored first, so a
1253
+ * failure between the two leaves the player linked rather than credential-less. Then whatever
1254
+ * this browser used to be is retired on the account it is leaving.
1255
+ *
1256
+ * **That retirement deletes the old account when this was its only device**, rather than
1257
+ * revoking the credential and walking away. Revoking the last device leaves an account nothing
1258
+ * can ever authenticate as, whose board rows and saved games are then unreachable by the player
1259
+ * and undeletable by anyone. An account with other devices only loses this one.
1260
+ *
1261
+ * So this call can destroy the progress held on THIS browser. Warn the player first; the docs
1262
+ * page has wording for it. The retirement is best-effort: if it fails the link still stands,
1263
+ * because failing a link that already worked is the worse direction.
1264
+ */
1265
+ redeemLinkCode(code: string): Promise<{
1266
+ account: string;
1267
+ }>;
1268
+ /** The devices on this account: ids and timestamps, plus which one this browser is. */
1269
+ devices(): Promise<{
1270
+ account: string;
1271
+ self: string;
1272
+ devices: {
1273
+ id: string;
1274
+ createdAt: string;
1275
+ lastSeen: string;
1276
+ }[];
1277
+ }>;
1278
+ /**
1279
+ * Revokes one device on this account. Revoking the device this browser IS leaves this instance
1280
+ * holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
1281
+ * mints a fresh player rather than looping on a refused exchange.
1282
+ */
1283
+ revokeDevice(deviceId: string): Promise<void>;
1284
+ /**
1285
+ * Deletes this account and everything keyed on it, in every project it played, and forgets the
1286
+ * credential. There is no undo and the control plane does not keep a copy.
1287
+ */
1288
+ deleteAccount(): Promise<void>;
1289
+ /** The account id, once anything has told us what it is. Opaque; never parse it. */
1290
+ get account(): string | undefined;
1291
+ /** One authenticated account-route call: credential in the header, project in the query. */
1292
+ private accountFetch;
1293
+ private post;
1294
+ }
1295
+
642
1296
  /**
643
1297
  * A client-side entity collection: the read API of `@irtio/schema`'s `ReadonlyCollection`, plus
644
1298
  * index sugar so `state.players[room.me]` reads the same as `state.players.get(room.me)`.
@@ -722,6 +1376,16 @@ interface RoomEvents {
722
1376
  status: Status;
723
1377
  error: RoomError;
724
1378
  correct: Correction;
1379
+ /**
1380
+ * Fires whenever the built-in presence collection changes (someone joins or leaves, or a
1381
+ * presence field updates), carrying the same array `room.clients` returns.
1382
+ */
1383
+ clients: readonly PresenceRecord[];
1384
+ /**
1385
+ * Fires after each `PONG` updates the smoothed round-trip time, about every `PING_INTERVAL_MS`
1386
+ * (2s), carrying the same value `room.rtt` returns.
1387
+ */
1388
+ rtt: number;
725
1389
  }
726
1390
  type Unsubscribe = () => void;
727
1391
  /**
@@ -797,12 +1461,32 @@ interface JoinOptions<S, Role extends string = string> {
797
1461
  * back a fresh token when the old one nears expiry. Omitted ⇒ the key-only join, unchanged.
798
1462
  */
799
1463
  readonly token?: string | (() => string | Promise<string>);
1464
+ /**
1465
+ * D53: join under an irtio anonymous persistent identity. `true` mints one on this browser's
1466
+ * first run, stores it, and exchanges it for a short-lived project-bound assertion before
1467
+ * every HELLO; an `Identity` instance is used as-is, so several joins can share one.
1468
+ *
1469
+ * `ctx.playerId` in room code becomes a stable `irt:<subject>` that survives reconnection,
1470
+ * hibernation and closing the tab — which is what a leaderboard keys on.
1471
+ *
1472
+ * The identity token itself never reaches the room. What rides the socket is the assertion:
1473
+ * bound to this one project, valid for minutes, and carrying a per-project pseudonym rather
1474
+ * than a platform id, so the game learns nothing about the player anywhere else. That is a
1475
+ * deliberate property and not an accident of the implementation — see `identity.ts`.
1476
+ *
1477
+ * Mutually exclusive with `token`: a join asserts one identity, and a client offering two
1478
+ * would be asking the server to pick.
1479
+ */
1480
+ readonly identity?: boolean | Identity;
1481
+ /** D53: the control plane origin the identity exchange talks to. Defaults to https://irt.io;
1482
+ * a local `irtio dev` has no control plane, so testing identities locally needs this. */
1483
+ readonly controlUrl?: string;
800
1484
  readonly onStatus?: (status: Status) => void;
801
1485
  /** Hard cap on the owned-write flush window, in ms. Default 50. */
802
1486
  readonly writeIntervalMs?: number;
803
1487
  /**
804
1488
  * 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.
1489
+ * `2000 / tickRate` (twice the room's tick interval, learned from `WELCOME`), floored at 50 ms.
806
1490
  */
807
1491
  readonly interpDelayMs?: number;
808
1492
  /**
@@ -814,15 +1498,38 @@ interface JoinOptions<S, Role extends string = string> {
814
1498
  * code-splits keeps Rapier out of the critical path entirely.
815
1499
  */
816
1500
  readonly physics?: ClientPhysicsOptions;
1501
+ /**
1502
+ * D57: the same contract for a matter2d room — `setup`, `bodies`, `intents`, plus `settle`,
1503
+ * the per-step pass over every local body of a collection that gives a crate nobody owns its
1504
+ * gravity. A sibling of `physics` rather than a variant inside it, because the two engines
1505
+ * take different functions; passing both is an error at join. The wire carries no engine
1506
+ * name, so passing the one that does not match the room is a game bug the client cannot see.
1507
+ */
1508
+ readonly physics2d?: ClientPhysics2dOptions;
817
1509
  /** @internal */
818
1510
  readonly transport?: Transport;
819
1511
  /** @internal */
820
1512
  readonly scheduler?: Scheduler;
821
1513
  /** @internal */
822
1514
  readonly onFrame?: FrameHook;
1515
+ /**
1516
+ * D65: keep a bandwidth ledger for this session, readable as `room.profile`. Off by default and
1517
+ * free when off. A development and diagnostics surface, not something to ship enabled.
1518
+ */
1519
+ readonly profile?: boolean;
823
1520
  }
824
- /** `joinRelay` options: a relay room has no schema, so there is no state and no RPC. */
825
- interface JoinRelayOptions {
1521
+ /**
1522
+ * `joinRelay` options. A relay room has no state and no RPC; D70 adds the one thing a schema can
1523
+ * still describe on it — `schema` names the project's deployed schema, which lets this client
1524
+ * bring its real hash instead of the zero hash and send typed messages.
1525
+ */
1526
+ interface JoinRelayOptions<S extends AnySchema = AnySchema> {
1527
+ /**
1528
+ * D70: the schema this project deployed with `irtio deploy` and no room file. The HELLO carries
1529
+ * its hash, the relay host checks it against what it was registered with, and `room.messages`
1530
+ * comes from it. Omitted ⇒ the zero hash and no typed messages, exactly as before.
1531
+ */
1532
+ readonly schema?: S;
826
1533
  readonly room?: string;
827
1534
  readonly role?: string;
828
1535
  readonly name?: string;
@@ -842,6 +1549,45 @@ interface JoinRelayOptions {
842
1549
  type MessageTarget = 'all' | string | {
843
1550
  readonly role: string;
844
1551
  };
1552
+ /**
1553
+ * D70: `room.stats.messages` — what this socket did with peer messages.
1554
+ *
1555
+ * `dropped` is the one to watch. It counts typed frames this client could not read: an index its
1556
+ * schema does not have, or a payload the codec refused. Every one of them is a peer running a
1557
+ * schema this client does not, so a number climbing here means a stale tab, a stale deploy, or
1558
+ * somebody probing — never a bug in the game reading it.
1559
+ */
1560
+ interface RoomMessageStats {
1561
+ readonly sent: number;
1562
+ readonly received: number;
1563
+ readonly dropped: number;
1564
+ }
1565
+ /** D70: `room.stats`. One member today; the shape exists so later counters have somewhere to go. */
1566
+ interface RoomStats {
1567
+ readonly messages: RoomMessageStats;
1568
+ }
1569
+ /**
1570
+ * D70: `room.messages` — one channel per shape the schema declares, `{}` when it declares none.
1571
+ *
1572
+ * `send` takes exactly the declared value and `on` hands back exactly the same, so a shape change
1573
+ * is a compile error on both sides at once. Fire and forget: there is no reply, no acknowledgement
1574
+ * and no ordering promise beyond one socket's own frames.
1575
+ */
1576
+ type RoomMessages<S> = MessageChannels<S, MessageTarget, 'server' | string, Unsubscribe>;
1577
+ /**
1578
+ * D65: the client's view of where its bytes went, by collection and field.
1579
+ *
1580
+ * A client cannot tell area-of-interest churn from a real spawn — after the encode the two are
1581
+ * the same bytes, and only the server knows which ops it synthesised. So every add and remove in
1582
+ * a `spatial-grid` collection lands under `churn` here, and surfaces that show it say
1583
+ * `enter/leave (incl. spawns)` rather than pretending otherwise.
1584
+ */
1585
+ interface RoomProfile {
1586
+ /** Cumulative bytes since the socket opened, by row. */
1587
+ total(): _irtio_protocol.ProfileSnapshot;
1588
+ /** Bytes per second over a rolling window of about a second. */
1589
+ perSecond(): _irtio_protocol.ProfileSnapshot;
1590
+ }
845
1591
  /**
846
1592
  * Default D21 cap on non-owned predicted bodies per client.
847
1593
  *
@@ -853,6 +1599,44 @@ type MessageTarget = 'all' | string | {
853
1599
  * a predicted body passes through them.
854
1600
  */
855
1601
  declare const MAX_PREDICTED_BODIES = 64;
1602
+ /**
1603
+ * D71: default cap on **kinematic proxies** per client — instances the local world does not
1604
+ * simulate but does have to be able to collide with (over `MAX_PREDICTED_BODIES`, or in a
1605
+ * collection that is not `predicted`).
1606
+ *
1607
+ * Its own number, measured rather than inherited from the prediction cap, because a proxy is a
1608
+ * different cost: a pose write and a collider in the broad phase, not a body being integrated and
1609
+ * solved. `packages/client/test/proxy-bench.test.ts` is the measurement and prints its whole table
1610
+ * on every run.
1611
+ *
1612
+ * Measured 2026-09-03 on the development machine (Windows 11, node 22), 64 predicted bodies plus N
1613
+ * proxies, all boxes in contact on a floor, median of 200 samples for a step and 20 for a
1614
+ * forty-step rebase:
1615
+ *
1616
+ * | N | rapier step | rapier rebase | matter step | matter rebase |
1617
+ * |---|---|---|---|---|
1618
+ * | 0 | 0.0055 ms | 6.66 ms | 0.0458 ms | 2.16 ms |
1619
+ * | 50 | 0.0146 | 7.71 | 0.0623 | 2.38 |
1620
+ * | 100 | 0.0096 | 8.01 | 0.0659 | 2.63 |
1621
+ * | 200 | 0.0118 | 7.22 | 0.0868 | 3.59 |
1622
+ * | 400 | 0.0217 | 7.56 | 0.1608 | 6.32 |
1623
+ *
1624
+ * So a proxy costs about 0.04 microseconds a step on Rapier and 0.29 on matter — three to four
1625
+ * orders of magnitude inside the 4 ms per step `games/dive/spike` budgets for the server's world,
1626
+ * which means the step budget does not bind at any count worth having. What binds is the rebase,
1627
+ * which runs on every authoritative arrival and has to fit inside a frame: at 200 both engines sit
1628
+ * at 3.6 to 7.2 ms against a 60 Hz frame's 16.7.
1629
+ *
1630
+ * The same bench inside a full `pnpm test`, with the rest of the suite running in parallel workers,
1631
+ * reads two to three times that: the step stays far inside its budget (0.023 ms rapier, 0.216 ms
1632
+ * matter at 200) but the rebase reaches 15.6 and 12.1 ms at 200 and 22.0 and 19.2 ms at 400. That
1633
+ * loaded reading is why 400 is not the default even though it fits on a quiet machine: a busy
1634
+ * device is the normal case, and the rebase is the number that stretches on one.
1635
+ *
1636
+ * Four times the prediction cap is also the honest ratio between the two costs. Raise it with
1637
+ * `maxProxyBodies` and watch `stats.lastResimMicros`, which is the rebase column measured live.
1638
+ */
1639
+ declare const MAX_PROXY_BODIES = 256;
856
1640
  /** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
857
1641
  declare const PREDICTION_EPSILON = 0.05;
858
1642
  /**
@@ -875,6 +1659,15 @@ interface PredictionStatus {
875
1659
  readonly active: boolean;
876
1660
  /** Is `collection[id]` currently simulated in the local world? */
877
1661
  predicts(collection: string, id: string): boolean;
1662
+ /**
1663
+ * D71: does `collection[id]` have a **kinematic proxy** in the local world — a collider at the
1664
+ * pose the renderer draws, which predicted bodies collide with and never move?
1665
+ *
1666
+ * Never true at the same time as `predicts`: an instance is simulated locally, or proxied, or
1667
+ * absent. `!predicts && !proxied` with the instance present is the absent case, and that is the
1668
+ * one a predicted body falls through.
1669
+ */
1670
+ proxied(collection: string, id: string): boolean;
878
1671
  readonly stats: PredictionStats;
879
1672
  }
880
1673
  /**
@@ -897,6 +1690,11 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
897
1690
  readonly link: string;
898
1691
  /** The last server tick this client saw. */
899
1692
  readonly tick: number;
1693
+ /**
1694
+ * The room's configured client cap (`WELCOME`), or `0` when unknown (a relay-only server
1695
+ * predating this field).
1696
+ */
1697
+ readonly maxClients: number;
900
1698
  readonly status: Status;
901
1699
  /** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
902
1700
  readonly rtt: number;
@@ -920,20 +1718,42 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
920
1718
  * counters the bot runtime and the demos report.
921
1719
  */
922
1720
  readonly prediction?: PredictionStatus;
1721
+ /**
1722
+ * D65: this session's bandwidth ledger, present only when the join passed `profile: true`.
1723
+ * `total()` is cumulative since the socket opened; `perSecond()` is a rolling window of about a
1724
+ * second, advanced on the session's own clock each time it is read.
1725
+ */
1726
+ readonly profile?: RoomProfile;
923
1727
  readonly call: RoomCallProxy<S>;
924
1728
  /** Convenience alias for `room.call.requestOwnership`. */
925
1729
  requestOwnership(entity: string, id: string): Promise<boolean>;
926
1730
  message(target: MessageTarget, bytes: Uint8Array): void;
927
1731
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
1732
+ /** D70: the declared message shapes. A raw `onMessage` callback never sees one of these. */
1733
+ readonly messages: RoomMessages<S>;
1734
+ /** D70: this socket's message counters, including typed frames it could not read. */
1735
+ readonly stats: RoomStats;
928
1736
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
929
1737
  /** Sends any pending owned writes immediately instead of at the next flush window. */
930
1738
  flush(): void;
931
1739
  leave(): void;
932
1740
  }
933
- /** What `joinRelay` returns: presence + the raw message channel, nothing else. */
934
- interface RelayRoom {
1741
+ /**
1742
+ * What `joinRelay` returns: presence and the message channel, nothing else.
1743
+ *
1744
+ * D70 gives it a type parameter. `joinRelay()` with no schema is `RelayRoom<never>` and behaves
1745
+ * exactly as it did — `messages` is `{}` — while `joinRelay({ schema })` against a project that
1746
+ * deployed one gets the same typed channels a coded room has. There is still no state and still
1747
+ * no RPC: a schema on a relay room describes messages, and lane K is what makes it describe more.
1748
+ */
1749
+ interface RelayRoom<S = never> {
935
1750
  readonly me: string;
936
1751
  readonly id: string;
1752
+ /**
1753
+ * The room's configured client cap (`WELCOME`), or `0` when unknown (a relay-only server
1754
+ * predating this field).
1755
+ */
1756
+ readonly maxClients: number;
937
1757
  readonly link: string;
938
1758
  readonly status: Status;
939
1759
  /** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
@@ -941,6 +1761,10 @@ interface RelayRoom {
941
1761
  readonly clients: readonly PresenceRecord[];
942
1762
  message(target: MessageTarget, bytes: Uint8Array): void;
943
1763
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
1764
+ /** D70: the declared message shapes; `{}` for a relay join that brought no schema. */
1765
+ readonly messages: RoomMessages<S>;
1766
+ /** D70: this socket's message counters, including typed frames it could not read. */
1767
+ readonly stats: RoomStats;
944
1768
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
945
1769
  leave(): void;
946
1770
  }
@@ -987,6 +1811,130 @@ declare function roomIdFrom(roomOrLink: string): string;
987
1811
  */
988
1812
  declare function linkForUrl(wsUrl: string, roomId: string): string;
989
1813
 
1814
+ /**
1815
+ * D52 client side: `matchRoom` — quick-match, then join.
1816
+ *
1817
+ * This is sugar over two things the SDK already does: one HTTP POST to the control plane, and
1818
+ * `joinRoom({ room })` with the code it answers. Nothing about the join differs from a friend
1819
+ * sharing a link, which is the property the whole design is built on — the room neither knows nor
1820
+ * cares that a matchmaker filled it.
1821
+ *
1822
+ * The wait is a long poll, so the request simply takes as long as the queue takes. A queue that
1823
+ * never fills answers "no match" at the deadline and `matchRoom` throws `E_NO_MATCH` rather than
1824
+ * silently dropping the player somewhere: a matchmaker that quietly degrades is one nobody can
1825
+ * debug, and a game that wants to show "still looking…" needs to be told.
1826
+ */
1827
+
1828
+ declare class MatchError extends Error {
1829
+ readonly code: string;
1830
+ readonly name = "MatchError";
1831
+ constructor(code: string, message: string);
1832
+ }
1833
+ interface MatchOptions {
1834
+ /** The queue to join. Omitted uses the project's `default` queue (party of two). */
1835
+ readonly queue?: string;
1836
+ /**
1837
+ * D63-g: a party code from `createParty`, so this player queues with their friends.
1838
+ *
1839
+ * Every member sends the same code with their own call. Nobody sends anybody else's identity:
1840
+ * a device credential is a secret that never leaves the browser that minted it, so a party is
1841
+ * a code you read out rather than a list you assemble.
1842
+ *
1843
+ * The queue fills only when the whole party is waiting, and the members land on consecutive
1844
+ * seats. A party bigger than the queue is refused with `E_PARTY_TOO_BIG` at the first call.
1845
+ */
1846
+ readonly party?: string;
1847
+ /**
1848
+ * How long to wait before giving up, in milliseconds. The control plane caps this at two
1849
+ * minutes and applies its own default (30 s) when omitted.
1850
+ */
1851
+ readonly timeoutMs?: number;
1852
+ /** The control plane origin. Defaults to `https://irt.io`. */
1853
+ readonly controlUrl?: string;
1854
+ /** Injected in tests. */
1855
+ readonly fetch?: typeof fetch;
1856
+ }
1857
+ /** What the queue answered. `seat` is a 0-based index into the party in arrival order. */
1858
+ interface MatchTicket {
1859
+ readonly room: string;
1860
+ readonly queue: string;
1861
+ /**
1862
+ * 0-based position in the party, in arrival order — **or `-1` for a backfill answer**, where
1863
+ * arrival order among a running room's players is not something the matchmaker knows.
1864
+ *
1865
+ * A game that uses `seat` as an index has to handle the `-1`. It is a value that cannot be
1866
+ * mistaken for a position, rather than an absent field, precisely so that a game which forgets
1867
+ * gets an obviously wrong answer instead of a plausible one.
1868
+ */
1869
+ readonly seat: number;
1870
+ readonly size: number;
1871
+ /**
1872
+ * True when this is a seat in a room that is already running rather than a fresh code.
1873
+ *
1874
+ * The room may fill before you get there — the matchmaker is working from an occupancy reading a
1875
+ * few seconds old — so a join off a backfill ticket can be refused with `E_ROOM_FULL`.
1876
+ * `matchRoom` handles that for you by queueing again, once.
1877
+ */
1878
+ readonly backfill: boolean;
1879
+ }
1880
+ /**
1881
+ * Queues for a game and resolves with the room code when the party fills.
1882
+ *
1883
+ * The identity, when one is supplied, is what gives the queue its one-ticket-per-player rule: a
1884
+ * player cannot fill a queue with themselves. Without one the queue falls back to limiting by
1885
+ * address, which is weaker (two players behind one NAT share a bucket) and is the honest reason
1886
+ * to adopt identities before shipping a quick-match button.
1887
+ */
1888
+ declare function findMatch(project: string, options?: MatchOptions & {
1889
+ readonly identity?: string;
1890
+ }): Promise<MatchTicket>;
1891
+ /** What `createParty` answers with. */
1892
+ interface PartyTicket {
1893
+ /** The code every member sends with their own `matchRoom` call. Readable out loud. */
1894
+ readonly party: string;
1895
+ /** How long the code stays good for. Ten minutes today. */
1896
+ readonly expiresInMs: number;
1897
+ }
1898
+ /**
1899
+ * Mints a party code so a group can queue together (D63-g).
1900
+ *
1901
+ * ```ts
1902
+ * const { party } = await createParty(schema.project, { size: 2 });
1903
+ * // read `party` out to your friend, then both of you:
1904
+ * const room = await matchRoom(schema, { queue: '2v2', party });
1905
+ * ```
1906
+ *
1907
+ * A code rather than a list of members, and the reason is worth knowing: an identity is a secret
1908
+ * that never leaves the browser that minted it, so there is no way for one player to hold
1909
+ * another's, and a ticket that claimed to speak for several players would be forgeable. Nobody
1910
+ * proves anything about anybody here — the matcher simply groups the tickets that carry the same
1911
+ * code — and the worst a guessed code can do is put a stranger in your game, which is what
1912
+ * quick-match does anyway.
1913
+ *
1914
+ * The code expires after `expiresInMs`. Expiry stops new members joining the party; members who
1915
+ * are already queued keep their tickets.
1916
+ */
1917
+ declare function createParty(project: string, options: {
1918
+ readonly size: number;
1919
+ } & Pick<MatchOptions, 'controlUrl' | 'fetch'>): Promise<PartyTicket>;
1920
+ /**
1921
+ * Quick-match, then join: the one call a quick-play button needs.
1922
+ *
1923
+ * ```ts
1924
+ * const room = await matchRoom(schema); // two strangers, one room, no code
1925
+ * const room = await matchRoom(schema, { queue: '1v1', identity: true });
1926
+ * ```
1927
+ *
1928
+ * With `identity: true` the player is minted an anonymous persistent identity (once, ever, stored
1929
+ * in `localStorage`), the queue enforces one ticket per identity, and the room sees a stable
1930
+ * `ctx.playerId` — which is what a leaderboard needs to key on.
1931
+ */
1932
+ declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S> & string>(schema: S, options?: MatchOptions & JoinOptions<S, Role> & {
1933
+ role?: RoleOf<S> & string;
1934
+ } & {
1935
+ readonly identity?: boolean;
1936
+ }): Promise<Room<S, Role>>;
1937
+
990
1938
  /**
991
1939
  * The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
992
1940
  * `WRITE` per rendered frame, which is what a game loop produces); everywhere else — and as a
@@ -1024,34 +1972,104 @@ declare const webSocketTransport: Transport;
1024
1972
 
1025
1973
  type AnyRecord$1 = Record<string, unknown>;
1026
1974
  declare class RenderStore {
1027
- private readonly ext;
1975
+ /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
1976
+ private ext;
1028
1977
  private readonly store;
1029
1978
  private readonly meOf;
1030
1979
  private readonly now;
1031
1980
  private readonly delayMs;
1981
+ /** The room's tick interval from `WELCOME`; 0 means unknown (pre-week-8 server). */
1982
+ private readonly intervalMs;
1032
1983
  /** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
1033
1984
  private readonly buffers;
1034
1985
  private readonly descs;
1986
+ /** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
1987
+ private readonly facades;
1035
1988
  /** The object handed out as `room.render`; identity survives a resync. */
1036
1989
  readonly view: AnyRecord$1;
1037
1990
  /** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
1038
1991
  starved: number;
1039
1992
  /** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
1040
1993
  private predictor;
1041
- constructor(ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number);
1994
+ /**
1995
+ * The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
1996
+ * is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
1997
+ */
1998
+ private base;
1999
+ /** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
2000
+ private baseAt;
2001
+ constructor(
2002
+ /** D50: not `readonly` — a schema swap replaces it in place (`swapSchema`). */
2003
+ ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number,
2004
+ /** The room's tick interval from `WELCOME`; 0 means unknown (pre-week-8 server). */
2005
+ intervalMs: () => number);
2006
+ /**
2007
+ * D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
2008
+ * it, exactly as `ClientStore.swapSchema` does.
2009
+ *
2010
+ * Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
2011
+ * out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
2012
+ * keyframe against a new one under the new field list is precisely the silent misread this
2013
+ * whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
2014
+ * re-seeds one keyframe per entity, which is the same state the buffers would have started
2015
+ * from on a reconnect.
2016
+ */
2017
+ swapSchema(newExt: AnySchema): void;
1042
2018
  /**
1043
2019
  * Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
1044
2020
  * the world is visible immediately (the join snapshot is the render baseline, not a change to
1045
2021
  * ease towards).
1046
2022
  */
1047
2023
  seedSnapshot(): void;
1048
- /** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
2024
+ /**
2025
+ * Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
2026
+ * and the caller must fall back to arrival stamping.
2027
+ *
2028
+ * The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
2029
+ * delayed delta seen so far is the best evidence of where the server's tick clock sits, and
2030
+ * every later delta is that plus its own queueing delay. So a better sample is taken
2031
+ * immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
2032
+ * render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
2033
+ * clock rather than a late frame and snaps.
2034
+ */
2035
+ private stampFor;
2036
+ private snapMs;
2037
+ /**
2038
+ * Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
2039
+ * burstiness cannot modulate the drawn velocity. Call after apply.
2040
+ */
1049
2041
  recordDelta(delta: Delta): void;
2042
+ /**
2043
+ * Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
2044
+ * depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
2045
+ * a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
2046
+ * appends; and an offset that just snapped backwards must not stamp behind what is buffered.
2047
+ */
2048
+ private pushFrame;
1050
2049
  private bufferFor;
1051
2050
  private renderTime;
1052
- attachPredictor(predictor: PhysicsPredictor): void;
2051
+ attachPredictor(predictor: Predictor): void;
1053
2052
  /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
1054
2053
  get(desc: CollectionDesc, id: string): unknown;
2054
+ /**
2055
+ * M6 lane D (D71): the body-channel values the renderer is drawing for one instance right now,
2056
+ * written into `into`. `true` when there is a pose to draw at all.
2057
+ *
2058
+ * This is what a kinematic proxy is moved to before every local step, and it deliberately does
2059
+ * **not** go through `get`. `get` asks the predictor first — which would recurse, since it calls
2060
+ * `frame()` — and for an owned or predicted instance it answers out of the local world, which is
2061
+ * the one answer a proxy must never be given (a proxy driven by the local world is a body driving
2062
+ * itself). So this is the D20 buffer and nothing else: the authoritative pose interpolated at
2063
+ * `interpDelayMs` behind arrival, held at the newest delta and never extrapolated past it.
2064
+ *
2065
+ * Allocation-free, and only the physics channels: it runs once per proxy per frame.
2066
+ *
2067
+ * Two things `get` does are left out on purpose. `starved` is not counted, because that number
2068
+ * belongs to the render path and a physics read landing in it would double it. And a buffer whose
2069
+ * entity has aged out is not `gc`'d here, because a read that drives physics must not decide when
2070
+ * the render path's buffers die.
2071
+ */
2072
+ drawn(desc: CollectionDesc, id: string, into: Record<string, number>): boolean;
1055
2073
  /** Is `collection[id]` visible at the render clock? */
1056
2074
  has(desc: CollectionDesc, id: string): boolean;
1057
2075
  /** The authoritative owner — ownership is fact, not a rendered value. */
@@ -1061,7 +2079,8 @@ declare class RenderStore {
1061
2079
  /** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
1062
2080
  private prune;
1063
2081
  private gc;
1064
- private buildView;
2082
+ /** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
2083
+ private buildViewInto;
1065
2084
  }
1066
2085
 
1067
2086
  /**
@@ -1098,35 +2117,67 @@ interface SessionOptions {
1098
2117
  * resume that outlives its token would otherwise be refused with `E_TOKEN_EXPIRED`.
1099
2118
  */
1100
2119
  readonly token?: string | (() => string | Promise<string>) | undefined;
2120
+ /**
2121
+ * D53: a provider for the platform identity assertion this session presents at HELLO.
2122
+ *
2123
+ * Always a function, never a string, and that is the point: an assertion lives for minutes
2124
+ * and a session lives for as long as the player plays, so a reconnect an hour in has to be
2125
+ * able to fetch a fresh one. A fixed string would be a session that dies at its first long
2126
+ * disconnect with `E_TOKEN_EXPIRED`.
2127
+ */
2128
+ readonly assertion?: (() => string | Promise<string>) | undefined;
1101
2129
  readonly role?: string | undefined;
1102
2130
  readonly name?: string | undefined;
1103
2131
  readonly rpc?: Readonly<Record<string, ClientImpl>> | undefined;
1104
2132
  readonly writeIntervalMs?: number | undefined;
1105
- /** Render delay for `room.render` (D20). Default `max(50, 2 × tickIntervalMs)`. */
2133
+ /** Render delay for `room.render` (D20). Default `max(50, 2000 / tickRate)`. */
1106
2134
  readonly interpDelayMs?: number | undefined;
1107
2135
  /** The shared world-builder half the client predicts with (D22 part 2). */
1108
2136
  readonly physics?: ClientPhysicsOptions | undefined;
2137
+ /** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
2138
+ readonly physics2d?: ClientPhysics2dOptions | undefined;
1109
2139
  readonly transport?: Transport | undefined;
1110
2140
  readonly scheduler?: Scheduler | undefined;
1111
2141
  readonly onFrame?: FrameHook | undefined;
2142
+ /** D65: keep a bandwidth ledger for this session. */
2143
+ readonly profile?: boolean | undefined;
1112
2144
  readonly onStatus?: ((status: Status) => void) | undefined;
1113
2145
  /** `true` in a browser with no explicit `room` option: `?room=` gets written back. */
1114
2146
  readonly publishLocation: boolean;
1115
2147
  }
1116
2148
  declare class Session {
1117
2149
  private readonly options;
1118
- readonly ext: AnySchema;
2150
+ /**
2151
+ * D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
2152
+ * new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
2153
+ * single assignment below is what actually moves the session onto the new schema.
2154
+ */
2155
+ ext: AnySchema;
1119
2156
  readonly store: ClientStore;
1120
2157
  readonly render: RenderStore;
1121
- /** `true` when the join passed `physics` and the schema has body-backed collections. */
2158
+ /** `true` when the join passed an engine option and the schema has body-backed collections. */
1122
2159
  readonly predictionRequested: boolean;
1123
2160
  /**
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.
2161
+ * Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
2162
+ * `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
2163
+ * engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
2164
+ * interpolation until then.
1126
2165
  */
1127
- predictor: PhysicsPredictor | undefined;
1128
- /** The schema the `CALL`/`REPLY` rpc id space indexes into. */
1129
- private readonly rpcSchema;
2166
+ predictor: Predictor | undefined;
2167
+ /** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
2168
+ private rpcSchema;
2169
+ /**
2170
+ * The BUILDER schema this session is currently on: `options.schema` at construction, then
2171
+ * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
2172
+ * swapped session announces its *current* hash when it reconnects rather than the one it was
2173
+ * born with — otherwise a resume after a swap would be refused as a mismatch.
2174
+ *
2175
+ * D70 made it internally readable rather than private: `buildMessages` reads the declared
2176
+ * shapes off it when the room object is built. Still not on the public `Room` type.
2177
+ */
2178
+ schema: AnySchema | undefined;
2179
+ /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
2180
+ schemaSwaps: number;
1130
2181
  private readonly transport;
1131
2182
  private readonly scheduler;
1132
2183
  private readonly writeIntervalMs;
@@ -1144,7 +2195,10 @@ declare class Session {
1144
2195
  */
1145
2196
  writeTick: number;
1146
2197
  /** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
2198
+ /** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
1147
2199
  tickIntervalMs: number;
2200
+ /** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
2201
+ maxClients: number;
1148
2202
  rtt: number;
1149
2203
  status: Status;
1150
2204
  private socket;
@@ -1169,6 +2223,38 @@ declare class Session {
1169
2223
  private readonly pending;
1170
2224
  private readonly listeners;
1171
2225
  private readonly messageListeners;
2226
+ /**
2227
+ * D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
2228
+ *
2229
+ * The isolation clause has a client half as well as a server half. The supervisor guarantees that
2230
+ * a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
2231
+ * *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
2232
+ * receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
2233
+ * the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
2234
+ * and no ordering between two callbacks to get wrong.
2235
+ *
2236
+ * Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
2237
+ * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
2238
+ */
2239
+ private readonly voiceListeners;
2240
+ /**
2241
+ * D70: typed-message listeners, by wire index, in their own map for the same structural reason
2242
+ * `voiceListeners` is its own set: a raw `onMessage` callback must never be handed a typed
2243
+ * frame and a typed callback must never be handed raw bytes. Splitting the registries makes
2244
+ * that a fact about the shape of the code rather than a filter somewhere in the fan-out.
2245
+ */
2246
+ private readonly typedListeners;
2247
+ /** D70: one warning per session for dropped typed messages — a flood must stay one line. */
2248
+ private typedDropWarned;
2249
+ /**
2250
+ * D70: what `room.stats.messages` reads. `dropped` is the one worth watching: it counts typed
2251
+ * frames this client could not decode, which is a peer on a schema this one does not have.
2252
+ */
2253
+ readonly messageCounts: {
2254
+ sent: number;
2255
+ received: number;
2256
+ dropped: number;
2257
+ };
1172
2258
  private cancelFlush;
1173
2259
  private cancelPing;
1174
2260
  private cancelRetry;
@@ -1191,10 +2277,78 @@ declare class Session {
1191
2277
  private fatal;
1192
2278
  private onSocketClosed;
1193
2279
  private stopTimers;
2280
+ /**
2281
+ * D65: the session's bandwidth ledger, present only when the join asked for one. A second
2282
+ * consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
2283
+ * compose rather than compete.
2284
+ */
2285
+ readonly ledger: ProfileLedger | undefined;
2286
+ /** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
2287
+ private profileWindow;
2288
+ /**
2289
+ * D65: bytes per second over a rolling window of about a second, measured on the scheduler's
2290
+ * clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
2291
+ *
2292
+ * The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
2293
+ * one-second window, and a reader that never calls costs nothing. A read sooner than a second
2294
+ * after the last one is extrapolated from the partial window rather than returning zeros.
2295
+ */
2296
+ profilePerSecond(): ProfileSnapshot;
1194
2297
  private send;
1195
2298
  private onFrame;
1196
2299
  /** A frame we could not decode or apply. Reported, never fatal: the stream may recover. */
1197
2300
  private localError;
2301
+ /**
2302
+ * D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
2303
+ * the old worker has stopped sending and before the resync WELCOME, so this session can stay
2304
+ * open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
2305
+ *
2306
+ * A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
2307
+ * the session on the old schema with a resync WELCOME about to arrive under the new one, which
2308
+ * it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
2309
+ * it anyway under the wrong descriptor, and a misdecode is silent.
2310
+ */
2311
+ private onSchema;
2312
+ /**
2313
+ * The one entry point for a mid-session schema change. Internal: not part of the public client
2314
+ * API this release, and not called from anywhere but `onSchema`.
2315
+ *
2316
+ * **What is rebuilt.** Every schema-derived handle in the client, top down:
2317
+ *
2318
+ * - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
2319
+ * `decodeSnapshot` and rpc-id lookup reads through;
2320
+ * - `descsByName`, the correction path's collection index, invalidated so it re-derives;
2321
+ * - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
2322
+ * facades behind `room.state` — retargeted, not replaced;
2323
+ * - `RenderStore`: the same, plus every interpolation buffer;
2324
+ * - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
2325
+ *
2326
+ * **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
2327
+ * by the old descriptors and has no correct reading under the new ones. The resync WELCOME
2328
+ * arriving immediately after re-establishes all of it, which is what makes dropping cheap:
2329
+ *
2330
+ * - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
2331
+ * - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
2332
+ * resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
2333
+ * indexed against descriptors that no longer exist;
2334
+ * - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
2335
+ * pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
2336
+ * the WELCOME;
2337
+ * - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
2338
+ *
2339
+ * **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
2340
+ * captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
2341
+ * was produced by a room that had those params in hand. So a call issued before the swap keeps
2342
+ * decoding its reply under the schema it was issued with, and resolves normally afterwards. This
2343
+ * costs nothing to implement — the descriptor is already captured per call rather than looked up
2344
+ * at reply time — and it is the honest semantics: the call did happen, under the old contract.
2345
+ *
2346
+ * The narrow case this leaves is a REPLY whose *shape* changed additively between the two
2347
+ * schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
2348
+ * which is exactly what an appended field means. A breaking change to an RPC never reaches here:
2349
+ * it classifies breaking and the session is closed instead.
2350
+ */
2351
+ private swapSchema;
1198
2352
  private onWelcome;
1199
2353
  private onDelta;
1200
2354
  private onCorrect;
@@ -1205,6 +2359,18 @@ declare class Session {
1205
2359
  private onError;
1206
2360
  private onPong;
1207
2361
  private onMsg;
2362
+ /**
2363
+ * D70: decode one typed payload against the current schema and hand it to that shape's
2364
+ * listeners.
2365
+ *
2366
+ * Everything a hostile peer can do here ends in the same place: a counter and a dropped frame.
2367
+ * An index past the schema's list, a truncated payload, an oversize `str`, an over-max `list`,
2368
+ * an unknown enum member, or a well-formed value of the wrong shape — each is caught, none
2369
+ * throws out of the frame loop, none closes the socket, and the next frame is delivered
2370
+ * normally. The `console.warn` fires once per session so a flood stays one line.
2371
+ */
2372
+ private deliverTyped;
2373
+ private dropTypedMessage;
1208
2374
  private startTimers;
1209
2375
  /**
1210
2376
  * The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
@@ -1226,6 +2392,25 @@ declare class Session {
1226
2392
  private rejectPending;
1227
2393
  message(target: MessageTarget, bytes: Uint8Array): void;
1228
2394
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
2395
+ /**
2396
+ * D70: send one typed message. `payload` is already `encodeFields`'d by the caller
2397
+ * (`buildMessages`), which is where the shape and the throw on a bad value belong.
2398
+ */
2399
+ typedMessage(index: number, target: MessageTarget, payload: Uint8Array): void;
2400
+ /** D70: subscribe to one message shape by wire index. Raw listeners never see these frames. */
2401
+ onTypedMessage(index: number, cb: (from: 'server' | string, value: Record<string, unknown>) => void): Unsubscribe;
2402
+ /**
2403
+ * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
2404
+ * so it is not on the `Room` type and a game cannot call it.
2405
+ *
2406
+ * This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
2407
+ * purpose (that is the third of the three isolation mechanisms), and widening it so that this
2408
+ * method could share the mapping would delete the mechanism to save four lines. Building the
2409
+ * `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
2410
+ */
2411
+ sendVoice(bytes: Uint8Array): void;
2412
+ /** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
2413
+ onVoice(cb: (bytes: Uint8Array) => void): Unsubscribe;
1229
2414
  get clients(): readonly PresenceRecord[];
1230
2415
  get link(): string;
1231
2416
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
@@ -1233,6 +2418,138 @@ declare class Session {
1233
2418
  private setStatus;
1234
2419
  }
1235
2420
 
2421
+ /**
2422
+ * D51: `joinVoice` — the client half of on-box voice.
2423
+ *
2424
+ * ## Why a free function and not `room.voice`
2425
+ *
2426
+ * `joinVoice(room)` mirrors `joinRoom(schema)`: a free function you call when you want the thing,
2427
+ * rather than a property that exists on every `Room` whether or not the game has voice. Three
2428
+ * reasons, in order of how much they matter.
2429
+ *
2430
+ * 1. **It keeps `mediasoup-client` off the join path.** The device library and its RTP capability
2431
+ * machinery are loaded by the first `joinVoice` call, dynamically. A game that never calls it
2432
+ * never pays for it, in bundle size or in startup work.
2433
+ * 2. **Voice is per-participant, not per-room.** A room can be joined by a spectator, an NPC or a
2434
+ * headless bot, none of which have a microphone; making voice a room property would suggest a
2435
+ * lifetime it does not have. The handle returned here is the lifetime.
2436
+ * 3. **It keeps the isolation clause structural.** Everything voice needs from the session —
2437
+ * `sendVoice`, `onVoice` — lives behind `INTERNAL_SESSION` and is absent from the public `Room`
2438
+ * type. Hanging voice off `Room` would have meant putting at least one of them within a game's
2439
+ * reach.
2440
+ *
2441
+ * ## Positional audio is not in this release
2442
+ *
2443
+ * The `positional` option is accepted and *ignored*. It is declared here rather than omitted so
2444
+ * that a game which already passes it keeps compiling and keeps working (with flat audio) rather
2445
+ * than failing to build, and so that turning it on later is an implementation change rather than an
2446
+ * API change. Passing `true` logs one warning and does nothing spatial: no panner, no listener
2447
+ * orientation, no distance model. See `mute` below for the one thing that *is* signalled.
2448
+ *
2449
+ * ## The handshake
2450
+ *
2451
+ * Fixed by the SFU's `dispatch`, and worth stating because the ordering is not obvious:
2452
+ *
2453
+ * join -> joined (both transports' parameters, the router's capabilities, current peers)
2454
+ * connect -> connected (per transport, fired lazily by mediasoup's own 'connect' event)
2455
+ * produce -> produced (our microphone; the SFU then tells peers via `peer-joined`)
2456
+ * consume -> consumed (per remote producer; the consumer starts PAUSED)
2457
+ * resume (no reply — the SFU acknowledges by letting packets flow)
2458
+ *
2459
+ * Consumers starting paused is mediasoup's documented sequence and the reason `resume` is a
2460
+ * separate message: the track is attached to its `<audio>` element before the first RTP packet
2461
+ * arrives, rather than racing it.
2462
+ *
2463
+ * Replies carry no request id, because the SFU's protocol has none. Correlation is therefore by
2464
+ * shape: `connected` by `transportId`, `consumed` by `producerId`, `joined` and `produced` by
2465
+ * being the only one outstanding. An `error` has nothing to correlate on at all, so it rejects
2466
+ * every outstanding waiter — which is what makes `E_VOICE_UNAVAILABLE` (a tenant with no SFU
2467
+ * configured, which is every box until O16 answers) come back as a rejected `joinVoice` rather
2468
+ * than a promise that hangs forever.
2469
+ */
2470
+
2471
+ /**
2472
+ * What is known about one other participant: what *they* did to their microphone, and what *you*
2473
+ * did to their playback on this machine. The two are deliberately separate fields rather than one
2474
+ * "silent" flag, because a UI that conflated them would tell a player they had muted somebody when
2475
+ * in fact that person had stopped talking.
2476
+ */
2477
+ interface VoicePeerState {
2478
+ /** The peer's own microphone mute, as last reported by the SFU. Not local. */
2479
+ readonly muted: boolean;
2480
+ /** This machine's local silencing of that peer. Never signalled, never seen by anyone else. */
2481
+ readonly mutedLocally: boolean;
2482
+ /** This machine's local playback volume for that peer, 0..1. */
2483
+ readonly volume: number;
2484
+ }
2485
+ interface VoiceHandle {
2486
+ /**
2487
+ * Mute or unmute the microphone. Signalled to the SFU rather than only stopped locally: the SFU
2488
+ * pauses the producer, which is what actually stops the bytes leaving the box, and an SFU that
2489
+ * merely saw a silent stream could not tell a muted participant from a quiet room.
2490
+ */
2491
+ mute(muted: boolean): void;
2492
+ readonly muted: boolean;
2493
+ /** The other participants currently in this room's voice call, by room client id. */
2494
+ readonly peers: readonly string[];
2495
+ /**
2496
+ * Local playback volume for one peer, 0..1 (clamped). Purely local: nothing is signalled, the
2497
+ * peer is not told, and every other participant hears them unchanged. Remembered for the life of
2498
+ * the handle, so setting it for a peer whose track has not arrived yet still takes effect when
2499
+ * it does.
2500
+ */
2501
+ setPeerVolume(peerId: string, volume: number): void;
2502
+ /**
2503
+ * Locally silence one peer. Distinct from the `peer-muted` event, which reports that peer's own
2504
+ * microphone: this one is yours, it is not signalled, and the two are reported separately by
2505
+ * `peerState`.
2506
+ */
2507
+ mutePeer(peerId: string, muted: boolean): void;
2508
+ /** The current state of one peer, or `undefined` if nobody by that id is in the call. */
2509
+ peerState(peerId: string): VoicePeerState | undefined;
2510
+ leave(): Promise<void>;
2511
+ on(event: 'peer-joined' | 'peer-left' | 'peer-muted' | 'peers-changed' | 'error', cb: (v: never) => void): () => void;
2512
+ }
2513
+ interface VoiceOptions {
2514
+ /**
2515
+ * **Not implemented in this release.** Accepted so that calling code compiles and runs, ignored
2516
+ * at runtime; passing `true` logs one warning and produces ordinary flat audio. Kept in the type
2517
+ * so that shipping it later is not a breaking change.
2518
+ */
2519
+ readonly positional?: boolean;
2520
+ }
2521
+ /**
2522
+ * The seam `@irtio/voice-ui` reads to draw a speaking indicator, and the only thing about a voice
2523
+ * call that is not on the public `VoiceHandle`.
2524
+ *
2525
+ * It is a *registered* symbol rather than a module-local one (unlike `INTERNAL_SESSION`, which
2526
+ * never leaves this package) because the reader is a different package: two copies of
2527
+ * `@irtio/client` in one bundle would otherwise mint two different symbols and the panel would
2528
+ * silently find nothing. Registered means "internal by convention, reachable across a package
2529
+ * boundary", which is exactly the contract here.
2530
+ *
2531
+ * It is not public API. The shape may change in any release; `@irtio/voice-ui` treats every field
2532
+ * as optional and degrades to no indicator when it is absent.
2533
+ */
2534
+ declare const INTERNAL_VOICE_TRACKS: unique symbol;
2535
+ /** What lives behind {@link INTERNAL_VOICE_TRACKS}. */
2536
+ interface VoiceTrackAccess {
2537
+ /** The remote track being played for one peer, if one has been attached. */
2538
+ peerTrack(peerId: string): MediaStreamTrack | undefined;
2539
+ /** This client's own microphone track, for a self speaking indicator. */
2540
+ micTrack(): MediaStreamTrack | undefined;
2541
+ }
2542
+ /**
2543
+ * Joins the voice call for a room this client is already in.
2544
+ *
2545
+ * Requires a microphone permission (the browser prompts) and a tenant with an SFU configured; a
2546
+ * tenant without one rejects with `E_VOICE_UNAVAILABLE` rather than hanging.
2547
+ *
2548
+ * `options.positional` is accepted and ignored — positional audio is not in this release. See the
2549
+ * module doc.
2550
+ */
2551
+ declare function joinVoice<S extends AnySchema, R extends string>(room: Room<S, R>, options?: VoiceOptions): Promise<VoiceHandle>;
2552
+
1236
2553
  /**
1237
2554
  * `@irtio/client` — the browser/Node SDK.
1238
2555
  *
@@ -1267,6 +2584,6 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
1267
2584
  * channel. A separate entry rather than a schema-less `joinRoom` overload, because everything a
1268
2585
  * `Room` promises about state would be a lie here.
1269
2586
  */
1270
- declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
2587
+ declare function joinRelay<S extends AnySchema = never>(options?: JoinRelayOptions<S extends AnySchema ? S : AnySchema>): Promise<RelayRoom<S>>;
1271
2588
 
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 };
2589
+ 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, MAX_PROXY_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 RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, 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 };