@irtio/client 0.6.0 → 0.8.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,7 @@
1
- import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
1
+ import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ClientImplementations, ClientRpcs, SchemaRpc, RoleOf, VisibleKeys, SchemaDefs, EntityDef, ReadonlyCollection, DeepReadonly, InferFields, Owned, SingletonDef, ClientCallProxy, MessageChannels } from '@irtio/schema';
2
2
  import * as _irtio_protocol from '@irtio/protocol';
3
3
  import { PresenceRecord, ProfileLedger, ProfileSnapshot } from '@irtio/protocol';
4
+ import RAPIER2D from '@dimforge/rapier2d-compat';
4
5
  import * as MATTER from 'matter-js';
5
6
  import RAPIER from '@dimforge/rapier3d-compat';
6
7
 
@@ -280,6 +281,23 @@ interface EngineAdapter {
280
281
  /** Factory call plus every engine-specific warning about what it returned. */
281
282
  createBody(desc: CollectionDesc, id: string, record: AnyRecord$2, warn: (key: string, message: string) => void): EngineBody | undefined;
282
283
  removeBody(body: EngineBody): void;
284
+ /**
285
+ * D71: turn a body the factory just built into a **kinematic proxy**. It keeps the collider the
286
+ * factory gave it, it is never moved by gravity, by contact or by any other local force, and it
287
+ * goes only where {@link EngineAdapter.moveKinematic} puts it. Called once, immediately after
288
+ * `createBody`, and never on a body the local world is simulating.
289
+ */
290
+ makeKinematic(body: EngineBody): void;
291
+ /**
292
+ * D71: put a kinematic proxy at `pose` for the next step, so that contacts see the velocity the
293
+ * move implies and a predicted body resting on it is carried.
294
+ *
295
+ * Called before every local step. Asking for the same pose twice moves nothing and implies no
296
+ * velocity — which is what "held, never extrapolated" means at the engine seam: a proxy driven
297
+ * through a forty-tick rebase stands still for all of it rather than running the same delta forty
298
+ * times.
299
+ */
300
+ moveKinematic(body: EngineBody, pose: Pose): void;
283
301
  /** One fixed-timestep step of the local world. */
284
302
  step(): void;
285
303
  /** Server record → body state, in whatever order this engine's setters require. */
@@ -302,9 +320,20 @@ interface EngineAdapter {
302
320
  */
303
321
  velocityTolerance(epsilon: number, timestepSeconds: number): number;
304
322
  }
323
+ /**
324
+ * D71: where a kinematic proxy goes — the body-channel values the renderer is drawing for one
325
+ * instance at this instant, written into `into`. `false` when there is nothing drawn (the entity
326
+ * has not appeared at the render clock, or has left it).
327
+ *
328
+ * Backed by `RenderStore.drawn`, the D20 interpolation buffer: the authoritative pose at
329
+ * `interpDelayMs` behind arrival, **held** past the newest delta rather than extrapolated. The
330
+ * predictor never asks it for a time, so it can never be asked for the future.
331
+ */
332
+ type DrawnReader = (desc: CollectionDesc, id: string, into: Record<string, number>) => boolean;
305
333
  /** The engine-neutral tuning knobs; both engines' option objects carry these names. */
306
334
  interface PredictorTuning {
307
335
  readonly maxPredictedBodies?: number;
336
+ readonly maxProxyBodies?: number;
308
337
  readonly epsilon?: number;
309
338
  readonly smoothingHalfLifeMs?: number;
310
339
  readonly smoothingSnapUnits?: number;
@@ -317,16 +346,42 @@ interface PredictionStats {
317
346
  resimSteps: number;
318
347
  /** Rebase passes (one per authoritative arrival batch). */
319
348
  rebases: number;
320
- /** Rebases whose lead outran the resim depth: the body snapped to authority. */
349
+ /**
350
+ * Rebases that rendered every body at authority. Since bugs.md #71 an outrun lead clamps
351
+ * instead of snapping (`leadClamped`), so nothing increments this today; the field stays
352
+ * because a zero here is a fence several suites hold, and a future hard-snap path would count
353
+ * here again.
354
+ */
321
355
  snaps: number;
356
+ /**
357
+ * Rebases whose lead outran `MAX_LEAD` and were re-stepped that many ticks instead (bugs.md
358
+ * #71). The local character still answers input; the anchor sits closer to authority than the
359
+ * round trip wants, so releases overshoot by the difference. A value that climbs and keeps
360
+ * climbing means the measured rtt is past the cap's horizon (667 ms at 60 Hz) — degraded
361
+ * prediction, where a snap would have been none.
362
+ */
363
+ leadClamped: number;
322
364
  /** Corrections whose values matched the local prediction within epsilon. */
323
365
  suppressed: number;
324
366
  /**
325
- * Non-owned predicted instances currently over the cap. They render by interpolation but have
326
- * no body in the local world, so predicted bodies pass through them: a non-zero value on a
327
- * collection anything stands on is a gameplay bug, not a fidelity tradeoff.
367
+ * Non-owned predicted instances currently over `maxPredictedBodies`.
368
+ *
369
+ * Since D71 these are **proxied** rather than absent: each one still has a collider in the local
370
+ * world, moved to the pose the renderer draws, so a predicted body stands on it instead of
371
+ * falling through. What it no longer has is a simulation of its own — shove it and nothing
372
+ * happens locally until the server agrees. `stats.absent` is the number that means "you can fall
373
+ * through this"; this one means "you cannot push this".
328
374
  */
329
375
  overCap: number;
376
+ /** D71: kinematic proxies in the local world right now. */
377
+ proxies: number;
378
+ /**
379
+ * D71: non-owned instances with **no body in the local world at all** — over `maxProxyBodies`,
380
+ * opted out with `proxy: false`, or with no body factory on the client. Predicted bodies pass
381
+ * straight through these until the next correction snaps them back, so a non-zero value on a
382
+ * collection anything stands on is a gameplay bug rather than a fidelity tradeoff.
383
+ */
384
+ absent: number;
330
385
  /** Microseconds spent in the last rebase's re-steps. */
331
386
  lastResimMicros: number;
332
387
  /**
@@ -389,11 +444,19 @@ declare class Predictor {
389
444
  /** Seconds per step, fixed for the life of the world. */
390
445
  private timestepSeconds;
391
446
  private readonly bodies;
447
+ /** D71: kinematic proxies, keyed like `bodies`. The two maps are disjoint by construction. */
448
+ private readonly proxies;
392
449
  /** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
393
450
  private collections;
394
451
  private readonly warned;
395
452
  /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
396
453
  private readonly overCapHigh;
454
+ /** The same, for the proxy cap: `absent` is the count a game has to act on (D71-d). */
455
+ private readonly absentHigh;
456
+ /** D71: where a proxy goes. Wired by the session; see {@link DrawnReader}. */
457
+ private drawnReader;
458
+ /** `drawnReader`'s output buffer, reused: it is called once per proxy per frame. */
459
+ private readonly drawnChannels;
397
460
  /** Authority arrived since the last frame: rebase before free-running. */
398
461
  private authorityDirty;
399
462
  private accumulatorMs;
@@ -466,6 +529,11 @@ declare class Predictor {
466
529
  * `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
467
530
  */
468
531
  swapSchema(newExt: AnySchema): void;
532
+ /**
533
+ * D71: tell the predictor where its proxies go. The session calls this once, with a reader over
534
+ * the D20 interpolation buffer; without it a proxy holds the pose it was built at.
535
+ */
536
+ setDrawnReader(reader: DrawnReader): void;
469
537
  get epsilon(): number;
470
538
  /** `true` once the engine is loaded and the local world exists. */
471
539
  get ready(): boolean;
@@ -509,8 +577,19 @@ declare class Predictor {
509
577
  * that is the lead, not this.
510
578
  */
511
579
  noteWriteApplied(stampTick: number, appliedTick: number): void;
512
- /** Does the local world currently simulate `collection[id]`? */
580
+ /**
581
+ * Does the local world currently **simulate** `collection[id]`?
582
+ *
583
+ * Deliberately still false for a proxied instance (D71). Every caller of this asks it to decide
584
+ * whether the local world is the better answer than authority — the render read path, the
585
+ * correction classifier, `room.prediction.predicts` — and for a proxy it is not: a proxy is
586
+ * authority, one interpolation delay old, put into the world so that other bodies can touch it.
587
+ * Reading it back would be a round trip through the physics engine to learn what the render
588
+ * buffer already said. {@link proxied} is the question about proxies.
589
+ */
513
590
  has(collection: string, id: string): boolean;
591
+ /** D71: does `collection[id]` have a kinematic proxy in the local world right now? */
592
+ hasProxy(collection: string, id: string): boolean;
514
593
  /**
515
594
  * Is a correction's every value within the suppression tolerance of the prediction it judges?
516
595
  * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
@@ -615,24 +694,77 @@ declare class Predictor {
615
694
  */
616
695
  predictedValues(desc: CollectionDesc, id: string, fields: readonly string[], atTick?: number): AnyRecord$2 | undefined;
617
696
  /**
618
- * Mirrors the local world's bodies onto the instances this client predicts: every physics
619
- * instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
620
- * (collection order, then insertion order the same stated iteration guarantee the server
621
- * follows, so which bodies fall over the cap is deterministic).
697
+ * Mirrors the local world onto the instances this client can see: a **simulated** body for every
698
+ * physics instance it owns plus non-owned instances of `predicted: true` collections up to
699
+ * `maxPredictedBodies`, and a **kinematic proxy** (D71) for everything else that has a body
700
+ * factory and has not opted out with `proxy: false`, up to `maxProxyBodies`.
701
+ *
702
+ * Slot order is collection order then insertion order — the same iteration guarantee the server
703
+ * states — so which instances fall over either cap is deterministic. There is no relevance
704
+ * policy and no distance ordering: the ninth crate spawned is still the ninth crate, it is just
705
+ * now solid rather than missing.
622
706
  *
623
- * Everything else non-`predicted` collections, and `predicted` instances over the cap gets
624
- * no body and no collider here. Those instances still render (the interpolation path reads
625
- * authoritative state directly), but nothing in the local world can touch them.
707
+ * Three transitions happen here, and each is a removal before a creation so the world never holds
708
+ * two colliders for one id:
709
+ *
710
+ * - **Promotion.** A proxied instance of a predicted collection finds a free slot (a predicted
711
+ * body was removed, or the cap was raised). `createBody` applies the authority record, velocity
712
+ * channels included, so the body carries on rather than starting from rest.
713
+ * - **Demotion.** A simulated instance loses its slot to an earlier-collection newcomer. That is
714
+ * what this loop already did before proxies — it stopped marking the body live and the sweep
715
+ * below removed it — and now it becomes a proxy at the drawn pose in the same frame. It costs
716
+ * one visible pose step: the body was a lead ahead of authority and the proxy is an
717
+ * interpolation delay behind it.
718
+ * - **Removal.** An instance the server removed, or one that left this client's area of interest
719
+ * (D23), loses whichever of the two it had, the same frame.
720
+ *
721
+ * What is left over — over the proxy cap, `proxy: false`, or no factory — has no collider in the
722
+ * local world at all, is counted in `stats.absent`, and is the only case a predicted body still
723
+ * falls through.
626
724
  */
627
725
  private reconcileBodies;
726
+ /** Drops one simulated body and everything the loop keeps per body. Safe on a missing key. */
727
+ private removePredicted;
728
+ /** Drops one proxy. Safe on a missing key. */
729
+ private removeProxy;
730
+ /**
731
+ * D71: builds one kinematic proxy through the **same factory call** the simulated path uses, so
732
+ * the collider is the server's collider by construction rather than by a second description of
733
+ * it, then hands it to the adapter to be made kinematic.
734
+ *
735
+ * The authority record is applied first — a proxy appears where the server last said it was, not
736
+ * at the factory's origin — and the drawn pose takes over on the next `refreshProxyTargets`.
737
+ */
738
+ private createProxy;
739
+ /**
740
+ * Reads every proxy's drawn pose once per frame, and only once.
741
+ *
742
+ * Once, because a rebase re-steps the world up to `MAX_LEAD` times against a render clock that
743
+ * has not moved: asking again per step would return the same answer at a real cost. And once
744
+ * *because* of D71's rule — a proxy holds one pose for every re-step of a rebase. It cannot run
745
+ * ahead of the newest delta because the reader never extrapolates, and it cannot run ahead of the
746
+ * frame because it is only ever asked here.
747
+ *
748
+ * A proxy with nothing drawn (a delta gap, an entity not yet at the render clock) keeps the pose
749
+ * it had. That is the hold, and it is the difference between a crate that stays solid through a
750
+ * stall and a crate that slides off across the level.
751
+ */
752
+ private refreshProxyTargets;
753
+ /** Writes every proxy to its target pose. Runs immediately before each `adapter.step()`. */
754
+ private driveProxies;
628
755
  private createBody;
629
756
  /**
630
- * Over-cap is not a one-time tuning notice: it means those instances are missing from the local
631
- * world right now, so a predicted body walks through them. Warn on every new high-water mark
632
- * per collection a game that grows past the cap mid-session hears about it, and a count that
633
- * oscillates around one level does not turn the console into a log.
757
+ * The two cap notices, both on a new high-water mark per collection: a game that grows past a cap
758
+ * mid-session hears about it, and a count that oscillates around one level does not turn the
759
+ * console into a log.
760
+ *
761
+ * They say different things now, and only the second is an emergency (D71). Over the *prediction*
762
+ * cap an instance is still solid — it has a proxy — it just is not simulated, so a shove does
763
+ * nothing locally until the server agrees. Over the *proxy* cap it is genuinely not there, which
764
+ * is the failure `bugs.md` #3 was about, and the message names both numbers so it is obvious
765
+ * which one to raise.
634
766
  */
635
- private warnOverCap;
767
+ private warnCaps;
636
768
  private warnOnce;
637
769
  /**
638
770
  * The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
@@ -670,8 +802,8 @@ declare class Predictor {
670
802
  * written, so plain state holds exactly what the server said), then re-step the world by the
671
803
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
672
804
  * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
673
- * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
674
- * authority and counts (`stats.snaps`).
805
+ * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead re-steps that
806
+ * many and counts (`stats.leadClamped`, bugs.md #71).
675
807
  */
676
808
  private rebase;
677
809
  /**
@@ -696,6 +828,11 @@ declare class Predictor {
696
828
  * local body of a collection, owned or not, run after the whole intent pass rather than
697
829
  * interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
698
830
  * whose world has none of its own.
831
+ *
832
+ * Both passes walk `this.bodies`, which is why neither ever reaches a proxy (D71): a proxy is not
833
+ * simulated, so an intent hook or a per-step gravity force on it would be a force on a body that
834
+ * cannot move, applied to a pose that is going to be overwritten before the next step anyway. The
835
+ * isolation is structural rather than a filter, which is the version that cannot rot.
699
836
  */
700
837
  private applyIntents;
701
838
  /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
@@ -703,6 +840,126 @@ declare class Predictor {
703
840
  private isF32;
704
841
  }
705
842
 
843
+ /**
844
+ * The rapier2d half of client-side physics prediction: `joinRoom({ physics2d: { engine:
845
+ * 'rapier2d', … } })`'s option type, the lazy `@dimforge/rapier2d-compat` load, and the adapter
846
+ * that is the only place in the client where a rapier2d type is touched.
847
+ *
848
+ * The loop is `predictor.ts`, shared byte for byte with the other two engines. This file is the
849
+ * seam list, and it is transcribed from `physics.ts` — the Rapier one — rather than from
850
+ * `physics2d.ts`, for the same reason `core/rapier2d.ts` is transcribed from `core/physics.ts`:
851
+ *
852
+ * - **The world applies gravity**, so there is no `settle` hook here and there must not be one.
853
+ * `EngineAdapter.settle` exists for matter2d, whose engine gravity is usually zero and whose
854
+ * rooms apply it per body; a rapier2d world that gained one would fall twice as fast on the
855
+ * client and be corrected every tick.
856
+ * - **Velocities are per second**, so `velocityTolerance` is `epsilon / dt`, the 3D answer, not
857
+ * matter's per-step one.
858
+ * - **The step is `world.step()`** against a `world.timestep` fixed once at start.
859
+ *
860
+ * What it takes from the 2D side is only the plane: `{ x, y }` gravity, a scalar rotation and a
861
+ * scalar spin, mapped onto the thirteen 3D pose numbers through `@irtio/schema`'s `channelOf2d` /
862
+ * `applyChannel2d` / `angleFrom2d`. That mapping is written once, in the schema package, and this
863
+ * file reads it rather than restating it — a second copy is a way for the two sides to disagree
864
+ * by a sign.
865
+ *
866
+ * There is no planar-lock warning here (bug 6's friction trap needs a third axis to lock, and this
867
+ * engine has none), and the engine is loaded lazily, so a game predicting with matter2d or rapier3d
868
+ * never pulls the 2D WASM in.
869
+ */
870
+
871
+ type ClientRapier2dModule = typeof RAPIER2D;
872
+ type ClientRapier2dWorld = RAPIER2D.World;
873
+ type ClientRapier2dBody = RAPIER2D.RigidBody;
874
+ interface ClientVector2d {
875
+ readonly x: number;
876
+ readonly y: number;
877
+ }
878
+ /**
879
+ * What a client-side rapier2d body factory returns — the same shape the room config's factories
880
+ * use (`Rapier2dBodySpec` in `@irtio/server`). Rapier bodies *have* colliders rather than being
881
+ * their geometry, which is the one structural difference from the matter2d spec.
882
+ */
883
+ interface ClientRapier2dBodySpec {
884
+ readonly body: RAPIER2D.RigidBodyDesc;
885
+ readonly colliders?: readonly RAPIER2D.ColliderDesc[];
886
+ }
887
+ /**
888
+ * Method-syntax members check bivariantly, so a builder's factory or intent hook written against
889
+ * its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
890
+ * the schema's records for that collection.
891
+ */
892
+ type ClientRapier2dBodyFactory = {
893
+ factory(rapier: ClientRapier2dModule, instance: AnyRecord$2, id: string): ClientRapier2dBodySpec;
894
+ }['factory'];
895
+ /**
896
+ * The client's half of `Rapier2dIntentHook`. Same five parameters in the same order as the server
897
+ * type, so one hook exported from a shared world module typechecks against both sides without a
898
+ * cast.
899
+ */
900
+ type ClientRapier2dIntentHook = {
901
+ hook(body: ClientRapier2dBody, instance: AnyRecord$2, rapier: ClientRapier2dModule, world: ClientRapier2dWorld, timestep: number): void;
902
+ }['hook'];
903
+ /**
904
+ * `joinRoom({ physics2d: { engine: 'rapier2d', … } })` — the client half of the shared
905
+ * world-builder contract for a rapier2d room.
906
+ *
907
+ * It rides inside the `physics2d` option rather than beside it because it is the same *option*:
908
+ * a planar world, a planar gravity, one 2D engine. `engine` is the discriminant, and it is
909
+ * required here and optional (defaulting to `'matter2d'`) on the matter2d member, so every
910
+ * matter2d room written before this engine existed still compiles unchanged.
911
+ *
912
+ * There is no `settle`. See the module docblock.
913
+ */
914
+ interface ClientRapier2dOptions {
915
+ /** The discriminant. Required: `physics2d` without one is a matter2d world. */
916
+ readonly engine: 'rapier2d';
917
+ /** Must equal the room config's gravity. Rapier's convention is y-up; nothing flips it. */
918
+ readonly gravity: ClientVector2d;
919
+ /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
920
+ readonly timestep?: number;
921
+ /** The shared static-geometry builder (the room's `physics.setup`). */
922
+ readonly setup?: (world: ClientRapier2dWorld, rapier: ClientRapier2dModule) => void;
923
+ /** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
924
+ readonly bodies?: Readonly<Record<string, ClientRapier2dBodyFactory>>;
925
+ /**
926
+ * Intent → force, applied before every predicted step for bodies this client owns — the same
927
+ * function the room's `tick()` calls per instance, shared so both simulations agree.
928
+ */
929
+ readonly intents?: Readonly<Record<string, ClientRapier2dIntentHook>>;
930
+ /**
931
+ * D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
932
+ * Over-cap instances get a kinematic proxy instead (see `maxProxyBodies`), so a predicted body
933
+ * still stands on them. Counted as `stats.overCap`.
934
+ */
935
+ readonly maxPredictedBodies?: number;
936
+ /**
937
+ * D71 cap: how many **kinematic proxies** this client keeps. Default `MAX_PROXY_BODIES`. Past
938
+ * it, instances are absent from the local world and predicted bodies pass through them (warned,
939
+ * counted as `stats.absent`). See `ClientPhysicsOptions.maxProxyBodies` for why it is its own
940
+ * number.
941
+ */
942
+ readonly maxProxyBodies?: number;
943
+ /**
944
+ * A body-field correction whose every value is within this tolerance of the local prediction is
945
+ * *suppressed*: authority still applies, but it is not a misprediction. Positions compare
946
+ * against `epsilon` world units; velocity channels against `epsilon / timestep`, because a
947
+ * rapier2d velocity is per second. Default 0.05, which is sized for a metre-scale world.
948
+ */
949
+ readonly epsilon?: number;
950
+ /**
951
+ * How fast the drawn position eases back onto the simulation after a re-simulation moved it, as
952
+ * a half-life in milliseconds. `0` turns the smoothing off. Default 70. See
953
+ * `ClientPhysicsOptions.smoothingHalfLifeMs` for why this smooths the error and not the motion.
954
+ */
955
+ readonly smoothingHalfLifeMs?: number;
956
+ /**
957
+ * How far the drawn position may be held from the simulation while an offset eases away, in
958
+ * world units. Past it the offset is dropped and the body appears where it is. Default 4.
959
+ */
960
+ readonly smoothingSnapUnits?: number;
961
+ }
962
+
706
963
  /**
707
964
  * The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
708
965
  * option type, the lazy `matter-js` load, and the adapter that is the only place in the client
@@ -777,6 +1034,11 @@ type ClientIntent2dHook = {
777
1034
  * Every function here should be the very export the room config imports.
778
1035
  */
779
1036
  interface ClientPhysics2dOptions {
1037
+ /**
1038
+ * The `physics2d` discriminant. Optional and defaulting to `'matter2d'`, so every matter2d
1039
+ * client written before rapier2d existed keeps compiling and keeps predicting with matter.
1040
+ */
1041
+ readonly engine?: 'matter2d';
780
1042
  /** Must equal the room config's gravity, in matter's own convention (y is down). */
781
1043
  readonly gravity: ClientVector2;
782
1044
  /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
@@ -798,12 +1060,22 @@ interface ClientPhysics2dOptions {
798
1060
  */
799
1061
  readonly settle?: Readonly<Record<string, ClientIntent2dHook>>;
800
1062
  /**
801
- * D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
802
- * are **absent from the local world** — they render by interpolation, but predicted bodies
803
- * pass straight through them, so anything a predicted body stands on or is blocked by has to
804
- * be under the cap (warned, counted as `stats.overCap`). Default 64.
1063
+ * D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
1064
+ *
1065
+ * Since D71 an over-cap instance is not gone from the local world it gets a kinematic proxy
1066
+ * (see `maxProxyBodies`), so a predicted body still stands on it and is still blocked by it.
1067
+ * What it loses is being simulated: shove it and nothing happens locally until the server says
1068
+ * it moved. Counted as `stats.overCap`.
805
1069
  */
806
1070
  readonly maxPredictedBodies?: number;
1071
+ /**
1072
+ * D71 cap: how many **kinematic proxies** this client keeps — colliders at the pose the renderer
1073
+ * draws, for the instances it does not simulate. Default `MAX_PROXY_BODIES`. Past it, instances
1074
+ * are **absent from the local world** and predicted bodies pass straight through them (warned,
1075
+ * counted as `stats.absent`). See `ClientPhysicsOptions.maxProxyBodies` for why it is its own
1076
+ * number rather than a share of the prediction cap.
1077
+ */
1078
+ readonly maxProxyBodies?: number;
807
1079
  /**
808
1080
  * A body-field correction whose every value is within this tolerance of the local prediction
809
1081
  * is *suppressed*: authority still applies, but it is not a misprediction. Positions compare
@@ -885,12 +1157,28 @@ interface ClientPhysicsOptions {
885
1157
  */
886
1158
  readonly intents?: Readonly<Record<string, ClientIntentHook>>;
887
1159
  /**
888
- * D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
889
- * are **absent from the local world** — they render by interpolation, but predicted bodies
890
- * pass straight through them, so anything a predicted body stands on or is blocked by has to
891
- * be under the cap (warned, counted as `stats.overCap`). Default 64.
1160
+ * D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
1161
+ *
1162
+ * Since D71 an over-cap instance is not gone from the local world it gets a kinematic proxy
1163
+ * (see `maxProxyBodies`), so a predicted body still stands on it and is still blocked by it.
1164
+ * What it loses is being simulated: shove it and nothing happens locally until the server says
1165
+ * it moved. Raise this for the bodies players push; leave it for the ones they only stand on.
1166
+ * Counted as `stats.overCap`.
892
1167
  */
893
1168
  readonly maxPredictedBodies?: number;
1169
+ /**
1170
+ * D71 cap: how many **kinematic proxies** this client keeps — colliders at the pose the renderer
1171
+ * draws, for the instances it does not simulate (over `maxPredictedBodies`, or in a collection
1172
+ * that is not `predicted`). Default `MAX_PROXY_BODIES`.
1173
+ *
1174
+ * Its own number rather than a share of `maxPredictedBodies`, because a proxy is a different
1175
+ * cost: a pose write and a collider in the broad phase, not a body being integrated and solved.
1176
+ * Past this cap instances are **absent from the local world** — predicted bodies pass through
1177
+ * them until the next correction snaps them back (warned, counted as `stats.absent`). Turn
1178
+ * `proxy: false` on in the schema for the collections nothing collides with, so the budget goes
1179
+ * to the ones that matter.
1180
+ */
1181
+ readonly maxProxyBodies?: number;
894
1182
  /**
895
1183
  * A body-field correction whose every value is within this tolerance of the local prediction
896
1184
  * is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
@@ -1356,7 +1644,7 @@ interface JoinOptions<S, Role extends string = string> {
1356
1644
  * take different functions; passing both is an error at join. The wire carries no engine
1357
1645
  * name, so passing the one that does not match the room is a game bug the client cannot see.
1358
1646
  */
1359
- readonly physics2d?: ClientPhysics2dOptions;
1647
+ readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions;
1360
1648
  /** @internal */
1361
1649
  readonly transport?: Transport;
1362
1650
  /** @internal */
@@ -1369,8 +1657,18 @@ interface JoinOptions<S, Role extends string = string> {
1369
1657
  */
1370
1658
  readonly profile?: boolean;
1371
1659
  }
1372
- /** `joinRelay` options: a relay room has no schema, so there is no state and no RPC. */
1373
- interface JoinRelayOptions {
1660
+ /**
1661
+ * `joinRelay` options. A relay room has no state and no RPC; D70 adds the one thing a schema can
1662
+ * still describe on it — `schema` names the project's deployed schema, which lets this client
1663
+ * bring its real hash instead of the zero hash and send typed messages.
1664
+ */
1665
+ interface JoinRelayOptions<S extends AnySchema = AnySchema> {
1666
+ /**
1667
+ * D70: the schema this project deployed with `irtio deploy` and no room file. The HELLO carries
1668
+ * its hash, the relay host checks it against what it was registered with, and `room.messages`
1669
+ * comes from it. Omitted ⇒ the zero hash and no typed messages, exactly as before.
1670
+ */
1671
+ readonly schema?: S;
1374
1672
  readonly room?: string;
1375
1673
  readonly role?: string;
1376
1674
  readonly name?: string;
@@ -1390,6 +1688,31 @@ interface JoinRelayOptions {
1390
1688
  type MessageTarget = 'all' | string | {
1391
1689
  readonly role: string;
1392
1690
  };
1691
+ /**
1692
+ * D70: `room.stats.messages` — what this socket did with peer messages.
1693
+ *
1694
+ * `dropped` is the one to watch. It counts typed frames this client could not read: an index its
1695
+ * schema does not have, or a payload the codec refused. Every one of them is a peer running a
1696
+ * schema this client does not, so a number climbing here means a stale tab, a stale deploy, or
1697
+ * somebody probing — never a bug in the game reading it.
1698
+ */
1699
+ interface RoomMessageStats {
1700
+ readonly sent: number;
1701
+ readonly received: number;
1702
+ readonly dropped: number;
1703
+ }
1704
+ /** D70: `room.stats`. One member today; the shape exists so later counters have somewhere to go. */
1705
+ interface RoomStats {
1706
+ readonly messages: RoomMessageStats;
1707
+ }
1708
+ /**
1709
+ * D70: `room.messages` — one channel per shape the schema declares, `{}` when it declares none.
1710
+ *
1711
+ * `send` takes exactly the declared value and `on` hands back exactly the same, so a shape change
1712
+ * is a compile error on both sides at once. Fire and forget: there is no reply, no acknowledgement
1713
+ * and no ordering promise beyond one socket's own frames.
1714
+ */
1715
+ type RoomMessages<S> = MessageChannels<S, MessageTarget, 'server' | string, Unsubscribe>;
1393
1716
  /**
1394
1717
  * D65: the client's view of where its bytes went, by collection and field.
1395
1718
  *
@@ -1415,6 +1738,44 @@ interface RoomProfile {
1415
1738
  * a predicted body passes through them.
1416
1739
  */
1417
1740
  declare const MAX_PREDICTED_BODIES = 64;
1741
+ /**
1742
+ * D71: default cap on **kinematic proxies** per client — instances the local world does not
1743
+ * simulate but does have to be able to collide with (over `MAX_PREDICTED_BODIES`, or in a
1744
+ * collection that is not `predicted`).
1745
+ *
1746
+ * Its own number, measured rather than inherited from the prediction cap, because a proxy is a
1747
+ * different cost: a pose write and a collider in the broad phase, not a body being integrated and
1748
+ * solved. `packages/client/test/proxy-bench.test.ts` is the measurement and prints its whole table
1749
+ * on every run.
1750
+ *
1751
+ * Measured 2026-09-03 on the development machine (Windows 11, node 22), 64 predicted bodies plus N
1752
+ * proxies, all boxes in contact on a floor, median of 200 samples for a step and 20 for a
1753
+ * forty-step rebase:
1754
+ *
1755
+ * | N | rapier step | rapier rebase | matter step | matter rebase |
1756
+ * |---|---|---|---|---|
1757
+ * | 0 | 0.0055 ms | 6.66 ms | 0.0458 ms | 2.16 ms |
1758
+ * | 50 | 0.0146 | 7.71 | 0.0623 | 2.38 |
1759
+ * | 100 | 0.0096 | 8.01 | 0.0659 | 2.63 |
1760
+ * | 200 | 0.0118 | 7.22 | 0.0868 | 3.59 |
1761
+ * | 400 | 0.0217 | 7.56 | 0.1608 | 6.32 |
1762
+ *
1763
+ * So a proxy costs about 0.04 microseconds a step on Rapier and 0.29 on matter — three to four
1764
+ * orders of magnitude inside the 4 ms per step `games/dive/spike` budgets for the server's world,
1765
+ * which means the step budget does not bind at any count worth having. What binds is the rebase,
1766
+ * which runs on every authoritative arrival and has to fit inside a frame: at 200 both engines sit
1767
+ * at 3.6 to 7.2 ms against a 60 Hz frame's 16.7.
1768
+ *
1769
+ * The same bench inside a full `pnpm test`, with the rest of the suite running in parallel workers,
1770
+ * reads two to three times that: the step stays far inside its budget (0.023 ms rapier, 0.216 ms
1771
+ * 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
1772
+ * loaded reading is why 400 is not the default even though it fits on a quiet machine: a busy
1773
+ * device is the normal case, and the rebase is the number that stretches on one.
1774
+ *
1775
+ * Four times the prediction cap is also the honest ratio between the two costs. Raise it with
1776
+ * `maxProxyBodies` and watch `stats.lastResimMicros`, which is the rebase column measured live.
1777
+ */
1778
+ declare const MAX_PROXY_BODIES = 256;
1418
1779
  /** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
1419
1780
  declare const PREDICTION_EPSILON = 0.05;
1420
1781
  /**
@@ -1437,6 +1798,15 @@ interface PredictionStatus {
1437
1798
  readonly active: boolean;
1438
1799
  /** Is `collection[id]` currently simulated in the local world? */
1439
1800
  predicts(collection: string, id: string): boolean;
1801
+ /**
1802
+ * D71: does `collection[id]` have a **kinematic proxy** in the local world — a collider at the
1803
+ * pose the renderer draws, which predicted bodies collide with and never move?
1804
+ *
1805
+ * Never true at the same time as `predicts`: an instance is simulated locally, or proxied, or
1806
+ * absent. `!predicts && !proxied` with the instance present is the absent case, and that is the
1807
+ * one a predicted body falls through.
1808
+ */
1809
+ proxied(collection: string, id: string): boolean;
1440
1810
  readonly stats: PredictionStats;
1441
1811
  }
1442
1812
  /**
@@ -1498,13 +1868,24 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
1498
1868
  requestOwnership(entity: string, id: string): Promise<boolean>;
1499
1869
  message(target: MessageTarget, bytes: Uint8Array): void;
1500
1870
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
1871
+ /** D70: the declared message shapes. A raw `onMessage` callback never sees one of these. */
1872
+ readonly messages: RoomMessages<S>;
1873
+ /** D70: this socket's message counters, including typed frames it could not read. */
1874
+ readonly stats: RoomStats;
1501
1875
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1502
1876
  /** Sends any pending owned writes immediately instead of at the next flush window. */
1503
1877
  flush(): void;
1504
1878
  leave(): void;
1505
1879
  }
1506
- /** What `joinRelay` returns: presence + the raw message channel, nothing else. */
1507
- interface RelayRoom {
1880
+ /**
1881
+ * What `joinRelay` returns: presence and the message channel, nothing else.
1882
+ *
1883
+ * D70 gives it a type parameter. `joinRelay()` with no schema is `RelayRoom<never>` and behaves
1884
+ * exactly as it did — `messages` is `{}` — while `joinRelay({ schema })` against a project that
1885
+ * deployed one gets the same typed channels a coded room has. There is still no state and still
1886
+ * no RPC: a schema on a relay room describes messages, and lane K is what makes it describe more.
1887
+ */
1888
+ interface RelayRoom<S = never> {
1508
1889
  readonly me: string;
1509
1890
  readonly id: string;
1510
1891
  /**
@@ -1519,6 +1900,10 @@ interface RelayRoom {
1519
1900
  readonly clients: readonly PresenceRecord[];
1520
1901
  message(target: MessageTarget, bytes: Uint8Array): void;
1521
1902
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
1903
+ /** D70: the declared message shapes; `{}` for a relay join that brought no schema. */
1904
+ readonly messages: RoomMessages<S>;
1905
+ /** D70: this socket's message counters, including typed frames it could not read. */
1906
+ readonly stats: RoomStats;
1522
1907
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1523
1908
  leave(): void;
1524
1909
  }
@@ -1688,6 +2073,230 @@ declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S>
1688
2073
  } & {
1689
2074
  readonly identity?: boolean;
1690
2075
  }): Promise<Room<S, Role>>;
2076
+ /** What a `mode: "public"` answer carries. */
2077
+ interface PublicTicket {
2078
+ readonly room: string;
2079
+ readonly queue: string;
2080
+ /** How many players the queue this lobby belongs to seats. */
2081
+ readonly size: number;
2082
+ /** True when this caller minted the room rather than joining one somebody else was waiting in. */
2083
+ readonly created: boolean;
2084
+ }
2085
+ /**
2086
+ * Asks the control plane for a public room of `queue`: the oldest open one, or a fresh code.
2087
+ *
2088
+ * The lower half of {@link joinPublic}, exported because the element's chooser is driven by events
2089
+ * rather than by a call, and an app that wires its own **Join a game** button may want the code
2090
+ * without the join.
2091
+ *
2092
+ * `exclude` names a room this caller already failed to join, so the second answer is a different
2093
+ * one. See `joinPublic` for when that happens.
2094
+ */
2095
+ declare function findPublic(project: string, options?: Pick<MatchOptions, 'queue' | 'controlUrl' | 'fetch'> & {
2096
+ readonly identity?: string;
2097
+ readonly exclude?: string;
2098
+ }): Promise<PublicTicket>;
2099
+ /**
2100
+ * Join a game: land in the next open public lobby, or open one and wait in it.
2101
+ *
2102
+ * ```ts
2103
+ * const room = await joinPublic(schema); // the default queue
2104
+ * const room = await joinPublic(schema, { queue: '4p', identity: true });
2105
+ * ```
2106
+ *
2107
+ * This is the other shape of matchmaking and it is deliberately not `matchRoom`. `matchRoom`
2108
+ * holds a long poll until a whole party is there and answers `E_NO_MATCH` if one never is;
2109
+ * `joinPublic` resolves on the first round trip and puts the player in a room that says "1/4
2110
+ * players". Waiting in a lobby you can see is a different experience from waiting on a spinner,
2111
+ * and it is the one a **Join a game** button should give.
2112
+ *
2113
+ * Nothing about the join differs from a friend sharing a link — the room neither knows nor cares
2114
+ * that the registry sent you — which is the property every part of this feature is built on.
2115
+ *
2116
+ * **The one retry.** The registry answers from an occupancy reading a few seconds old, so the room
2117
+ * may have filled on the way there. That is refused with `E_ROOM_FULL`, and the honest recovery is
2118
+ * to ask once more, naming the room that did not work so the second answer cannot be the same one.
2119
+ * Once, and never a loop: a retry that kept going would turn a busy game into a client that
2120
+ * hammers control, and a second failure means something a third attempt will not fix.
2121
+ */
2122
+ declare function joinPublic<S extends AnySchema, Role extends string = RoleOf<S> & string>(schema: S, options?: Pick<MatchOptions, 'queue' | 'controlUrl' | 'fetch'> & JoinOptions<S, Role> & {
2123
+ role?: RoleOf<S> & string;
2124
+ } & {
2125
+ readonly identity?: boolean;
2126
+ }): Promise<Room<S, Role>>;
2127
+
2128
+ /**
2129
+ * D75 (M6 lane H): the client's half of the lobby, and the glue that wires `<irt-lobby>` up.
2130
+ *
2131
+ * Two exports and a clean division between them.
2132
+ *
2133
+ * `lobbyOf(room)` is the **view**: it reads the lobby fragment out of ordinary room state and
2134
+ * answers the structural shape `@irtio/lobby`'s `AttachableRoom.lobby` asks for. It is a reader
2135
+ * plus one owner write (the ready flag), and it holds nothing the room does not already say.
2136
+ *
2137
+ * `attachLobby(element, schema, options)` is the **glue**: it listens for the panel's two chooser
2138
+ * events, makes the calls the panel deliberately cannot make itself, and attaches the room it gets
2139
+ * back. Sugar, not capability — the same rule `matchRoom` is held to. An app that wants to answer
2140
+ * the events itself keeps every part of this and needs none of it.
2141
+ *
2142
+ * ## Why the view polls
2143
+ *
2144
+ * `room.on(...)` has no "state changed" event: the client SDK publishes `status`, `clients`, `rtt`,
2145
+ * `error` and `correct`, and lobby state is ordinary entity state that arrives in a `DELTA` like
2146
+ * everything else. So `on('change')` samples — on every `clients` event, and on a cheap timer in
2147
+ * between — and fires only when a small signature actually differs.
2148
+ *
2149
+ * That is a real cost and it is written here rather than hidden: one shallow string build per
2150
+ * sample, over at most `maxClients` records, for as long as somebody is watching. It is the honest
2151
+ * price of the element importing nothing from this package. A `state` event on `Room` would replace
2152
+ * the timer with a subscription and is recorded as a debt in the lane report rather than smuggled
2153
+ * into this lane's protocol-free budget.
2154
+ */
2155
+
2156
+ /**
2157
+ * The two collection names the lobby fragment declares.
2158
+ *
2159
+ * Literals here rather than an import from `@irtio/server`, which is where they are defined. The
2160
+ * client SDK ships to browsers and depends on `@irtio/schema` and `@irtio/protocol` alone; taking
2161
+ * a dependency on the room-file API to read two strings would put the whole of it in every game's
2162
+ * bundle. This is the same fence `@irtio/server`'s own `MAX_AWAKE_MAX` sits behind, and it is
2163
+ * pinned the same way: `test/lobby-view.test.ts` imports both and asserts the pairs equal, from a
2164
+ * package that legitimately depends on both, so a rename is a failing test rather than a lobby
2165
+ * that silently never appears.
2166
+ */
2167
+ declare const LOBBY_STATE = "irtLobby";
2168
+ declare const LOBBY_MEMBERS = "irtLobbyMembers";
2169
+ /** How often the view samples for a change between `clients` events. */
2170
+ declare const LOBBY_POLL_MS = 120;
2171
+ /** One row of the lobby roster, in join order. */
2172
+ interface LobbyPlayer {
2173
+ readonly clientId: string;
2174
+ readonly ready: boolean;
2175
+ readonly connected: boolean;
2176
+ /** True for the row belonging to the client holding this view. */
2177
+ readonly me: boolean;
2178
+ }
2179
+ /** What `lobbyOf` answers: `@irtio/lobby`'s structural `lobby` member, with names. */
2180
+ interface LobbyView {
2181
+ readonly phase: 'lobby' | 'started';
2182
+ readonly players: readonly LobbyPlayer[];
2183
+ readonly public: boolean;
2184
+ readonly readyUi: boolean;
2185
+ /** How many the room starts at under `'when-full'`, or 0 when the room has not said. */
2186
+ readonly capacity: number;
2187
+ ready(value: boolean): void;
2188
+ setPublic?(value: boolean): void;
2189
+ on(event: 'change', cb: () => void): () => void;
2190
+ }
2191
+ interface StateLike {
2192
+ [collection: string]: unknown;
2193
+ }
2194
+ interface RoomLike {
2195
+ readonly me: string;
2196
+ readonly state: StateLike;
2197
+ readonly clients: readonly {
2198
+ clientId: string;
2199
+ connected: boolean;
2200
+ }[];
2201
+ on(event: 'clients', cb: (clients: readonly {
2202
+ clientId: string;
2203
+ connected: boolean;
2204
+ }[]) => void): () => void;
2205
+ flush(): void;
2206
+ }
2207
+ /**
2208
+ * Does this room carry a lobby?
2209
+ *
2210
+ * A schema that did not spread `lobbyCollections` has neither collection, and the honest answer for
2211
+ * such a room is that it has no lobby rather than an empty one — `<irt-lobby>` renders no lobby UI
2212
+ * at all in that case, which is what keeps every existing consumer unchanged.
2213
+ */
2214
+ declare function hasLobby(room: unknown): boolean;
2215
+ /**
2216
+ * Reads a joined room's lobby.
2217
+ *
2218
+ * ```ts
2219
+ * const room = await joinPublic(schema);
2220
+ * lobby.attach(room, { lobby: lobbyOf(room) });
2221
+ * ```
2222
+ *
2223
+ * `setPublic` is present only when the caller supplies one, and that is a consequence of there
2224
+ * being no built-in RPC for it. The platform never takes a public toggle from a client — that
2225
+ * would be a protocol change — so the room's own game code owns the door. A game that wants the
2226
+ * panel's toggle to work declares its own RPC and passes it here:
2227
+ *
2228
+ * ```ts
2229
+ * lobbyOf(room, { setPublic: (value) => void room.call.setPublic({ value }) })
2230
+ * ```
2231
+ *
2232
+ * Without one, the panel renders the room's public state and no toggle, which is the truthful
2233
+ * rendering of a room whose code has not offered the control.
2234
+ */
2235
+ declare function lobbyOf(room: unknown, options?: {
2236
+ readonly setPublic?: (value: boolean) => void;
2237
+ }): LobbyView | undefined;
2238
+ /** What the panel's `quickmatch` event carries. */
2239
+ interface QuickMatchDetail {
2240
+ readonly queue?: string;
2241
+ }
2242
+ /** The element surface `attachLobby` needs. Structural, so a test can pass a stand-in. */
2243
+ interface LobbyElementLike {
2244
+ addEventListener(type: string, cb: (event: Event) => void): void;
2245
+ removeEventListener(type: string, cb: (event: Event) => void): void;
2246
+ setAttribute(name: string, value: string): void;
2247
+ removeAttribute(name: string): void;
2248
+ attach(room: unknown): () => void;
2249
+ }
2250
+ /**
2251
+ * The room, as `<irt-lobby>`'s structural `AttachableRoom` wants it: everything the panel already
2252
+ * read, plus the optional `lobby` member the design's §6 adds.
2253
+ *
2254
+ * A wrapper of getters rather than a spread, because a `Room`'s fields are accessors on a class:
2255
+ * `{ ...room }` would freeze `status`, `id` and `clients` at the moment of the copy, and the panel
2256
+ * re-reads all three on every render precisely so a reconnect that changes the code is picked up.
2257
+ * Mutating the room to hang a `lobby` on it was the other option and is worse — the SDK's own
2258
+ * object would then carry a field its type does not declare.
2259
+ */
2260
+ declare function attachable(room: RoomLike & {
2261
+ id: string;
2262
+ link: string;
2263
+ status: string;
2264
+ maxClients?: number;
2265
+ rtt?: number;
2266
+ }, view: LobbyView | undefined): unknown;
2267
+ interface AttachLobbyOptions<S extends AnySchema, Role extends string> {
2268
+ /** Passed through to `joinRoom` / `joinPublic`. */
2269
+ readonly join?: JoinOptions<S, Role> & {
2270
+ role?: RoleOf<S> & string;
2271
+ };
2272
+ /** Passed through to the control-plane call. */
2273
+ readonly match?: Pick<MatchOptions, 'controlUrl' | 'fetch'> & {
2274
+ readonly identity?: boolean;
2275
+ };
2276
+ /** The room's own public-toggle RPC, if it has one. See `lobbyOf`. */
2277
+ readonly setPublic?: (room: Room<S, Role>, value: boolean) => void;
2278
+ /** Called with the room once it is joined, so game code can start drawing. */
2279
+ readonly onRoom?: (room: Room<S, Role>) => void;
2280
+ /** Called when a join fails. Default: the panel's status goes back to idle and the error is
2281
+ * rethrown on the microtask queue so it reaches `window.onerror` rather than vanishing. */
2282
+ readonly onError?: (err: unknown) => void;
2283
+ }
2284
+ /**
2285
+ * Wires `<irt-lobby>`'s chooser to the two calls it deliberately cannot make itself.
2286
+ *
2287
+ * ```ts
2288
+ * attachLobby(document.querySelector('irt-lobby')!, schema, { onRoom: (room) => start(room) });
2289
+ * ```
2290
+ *
2291
+ * The panel emits and this answers: `private` becomes an ordinary `joinRoom` with no code (which
2292
+ * creates one), `quickmatch` becomes `joinPublic`. Both then `attach` the room, exactly as an app
2293
+ * doing it by hand would. Returns an unsubscribe.
2294
+ *
2295
+ * `@irtio/lobby` imports nothing from this package and never will — that is what lets a React or
2296
+ * Svelte app drive the panel with attributes and no SDK. This function is the other side of that
2297
+ * rule rather than an exception to it: the dependency points this way.
2298
+ */
2299
+ declare function attachLobby<S extends AnySchema, Role extends string = RoleOf<S> & string>(element: LobbyElementLike, schema: S, options?: AttachLobbyOptions<S, Role>): () => void;
1691
2300
 
1692
2301
  /**
1693
2302
  * The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
@@ -1805,6 +2414,25 @@ declare class RenderStore {
1805
2414
  attachPredictor(predictor: Predictor): void;
1806
2415
  /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
1807
2416
  get(desc: CollectionDesc, id: string): unknown;
2417
+ /**
2418
+ * M6 lane D (D71): the body-channel values the renderer is drawing for one instance right now,
2419
+ * written into `into`. `true` when there is a pose to draw at all.
2420
+ *
2421
+ * This is what a kinematic proxy is moved to before every local step, and it deliberately does
2422
+ * **not** go through `get`. `get` asks the predictor first — which would recurse, since it calls
2423
+ * `frame()` — and for an owned or predicted instance it answers out of the local world, which is
2424
+ * the one answer a proxy must never be given (a proxy driven by the local world is a body driving
2425
+ * itself). So this is the D20 buffer and nothing else: the authoritative pose interpolated at
2426
+ * `interpDelayMs` behind arrival, held at the newest delta and never extrapolated past it.
2427
+ *
2428
+ * Allocation-free, and only the physics channels: it runs once per proxy per frame.
2429
+ *
2430
+ * Two things `get` does are left out on purpose. `starved` is not counted, because that number
2431
+ * belongs to the render path and a physics read landing in it would double it. And a buffer whose
2432
+ * entity has aged out is not `gc`'d here, because a read that drives physics must not decide when
2433
+ * the render path's buffers die.
2434
+ */
2435
+ drawn(desc: CollectionDesc, id: string, into: Record<string, number>): boolean;
1808
2436
  /** Is `collection[id]` visible at the render clock? */
1809
2437
  has(desc: CollectionDesc, id: string): boolean;
1810
2438
  /** The authoritative owner — ownership is fact, not a rendered value. */
@@ -1870,7 +2498,7 @@ interface SessionOptions {
1870
2498
  /** The shared world-builder half the client predicts with (D22 part 2). */
1871
2499
  readonly physics?: ClientPhysicsOptions | undefined;
1872
2500
  /** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
1873
- readonly physics2d?: ClientPhysics2dOptions | undefined;
2501
+ readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions | undefined;
1874
2502
  readonly transport?: Transport | undefined;
1875
2503
  readonly scheduler?: Scheduler | undefined;
1876
2504
  readonly onFrame?: FrameHook | undefined;
@@ -1906,8 +2534,11 @@ declare class Session {
1906
2534
  * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
1907
2535
  * swapped session announces its *current* hash when it reconnects rather than the one it was
1908
2536
  * born with — otherwise a resume after a swap would be refused as a mismatch.
2537
+ *
2538
+ * D70 made it internally readable rather than private: `buildMessages` reads the declared
2539
+ * shapes off it when the room object is built. Still not on the public `Room` type.
1909
2540
  */
1910
- private schema;
2541
+ schema: AnySchema | undefined;
1911
2542
  /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
1912
2543
  schemaSwaps: number;
1913
2544
  private readonly transport;
@@ -1969,6 +2600,24 @@ declare class Session {
1969
2600
  * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
1970
2601
  */
1971
2602
  private readonly voiceListeners;
2603
+ /**
2604
+ * D70: typed-message listeners, by wire index, in their own map for the same structural reason
2605
+ * `voiceListeners` is its own set: a raw `onMessage` callback must never be handed a typed
2606
+ * frame and a typed callback must never be handed raw bytes. Splitting the registries makes
2607
+ * that a fact about the shape of the code rather than a filter somewhere in the fan-out.
2608
+ */
2609
+ private readonly typedListeners;
2610
+ /** D70: one warning per session for dropped typed messages — a flood must stay one line. */
2611
+ private typedDropWarned;
2612
+ /**
2613
+ * D70: what `room.stats.messages` reads. `dropped` is the one worth watching: it counts typed
2614
+ * frames this client could not decode, which is a peer on a schema this one does not have.
2615
+ */
2616
+ readonly messageCounts: {
2617
+ sent: number;
2618
+ received: number;
2619
+ dropped: number;
2620
+ };
1972
2621
  private cancelFlush;
1973
2622
  private cancelPing;
1974
2623
  private cancelRetry;
@@ -2073,6 +2722,18 @@ declare class Session {
2073
2722
  private onError;
2074
2723
  private onPong;
2075
2724
  private onMsg;
2725
+ /**
2726
+ * D70: decode one typed payload against the current schema and hand it to that shape's
2727
+ * listeners.
2728
+ *
2729
+ * Everything a hostile peer can do here ends in the same place: a counter and a dropped frame.
2730
+ * An index past the schema's list, a truncated payload, an oversize `str`, an over-max `list`,
2731
+ * an unknown enum member, or a well-formed value of the wrong shape — each is caught, none
2732
+ * throws out of the frame loop, none closes the socket, and the next frame is delivered
2733
+ * normally. The `console.warn` fires once per session so a flood stays one line.
2734
+ */
2735
+ private deliverTyped;
2736
+ private dropTypedMessage;
2076
2737
  private startTimers;
2077
2738
  /**
2078
2739
  * The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
@@ -2094,6 +2755,13 @@ declare class Session {
2094
2755
  private rejectPending;
2095
2756
  message(target: MessageTarget, bytes: Uint8Array): void;
2096
2757
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
2758
+ /**
2759
+ * D70: send one typed message. `payload` is already `encodeFields`'d by the caller
2760
+ * (`buildMessages`), which is where the shape and the throw on a bad value belong.
2761
+ */
2762
+ typedMessage(index: number, target: MessageTarget, payload: Uint8Array): void;
2763
+ /** D70: subscribe to one message shape by wire index. Raw listeners never see these frames. */
2764
+ onTypedMessage(index: number, cb: (from: 'server' | string, value: Record<string, unknown>) => void): Unsubscribe;
2097
2765
  /**
2098
2766
  * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
2099
2767
  * so it is not on the `Room` type and a game cannot call it.
@@ -2279,6 +2947,6 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
2279
2947
  * channel. A separate entry rather than a schema-less `joinRoom` overload, because everything a
2280
2948
  * `Room` promises about state would be a lie here.
2281
2949
  */
2282
- declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
2950
+ declare function joinRelay<S extends AnySchema = never>(options?: JoinRelayOptions<S extends AnySchema ? S : AnySchema>): Promise<RelayRoom<S>>;
2283
2951
 
2284
- export { ACCOUNT_STORAGE_KEY, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomProfile, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, createParty, defaultScheduler, findMatch, identityStorageKey, joinRelay, joinRoom, joinVoice, linkForUrl, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };
2952
+ export { ACCOUNT_STORAGE_KEY, type AttachLobbyOptions, 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 ClientRapier2dBody, type ClientRapier2dBodyFactory, type ClientRapier2dBodySpec, type ClientRapier2dIntentHook, type ClientRapier2dModule, type ClientRapier2dOptions, type ClientRapier2dWorld, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector2d, 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, LOBBY_MEMBERS, LOBBY_POLL_MS, LOBBY_STATE, type LobbyPlayer, type LobbyView, 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, type PublicTicket, type QuickMatchDetail, 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, attachLobby, attachable, createParty, defaultScheduler, findMatch, findPublic, hasLobby, identityStorageKey, joinPublic, joinRelay, joinRoom, joinVoice, linkForUrl, lobbyOf, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };