@irtio/client 0.6.0 → 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,4 +1,4 @@
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, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, MessageChannels, ClientCallProxy } from '@irtio/schema';
2
2
  import * as _irtio_protocol from '@irtio/protocol';
3
3
  import { PresenceRecord, ProfileLedger, ProfileSnapshot } from '@irtio/protocol';
4
4
  import * as MATTER from 'matter-js';
@@ -280,6 +280,23 @@ interface EngineAdapter {
280
280
  /** Factory call plus every engine-specific warning about what it returned. */
281
281
  createBody(desc: CollectionDesc, id: string, record: AnyRecord$2, warn: (key: string, message: string) => void): EngineBody | undefined;
282
282
  removeBody(body: EngineBody): void;
283
+ /**
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.
288
+ */
289
+ makeKinematic(body: EngineBody): void;
290
+ /**
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;
283
300
  /** One fixed-timestep step of the local world. */
284
301
  step(): void;
285
302
  /** Server record → body state, in whatever order this engine's setters require. */
@@ -302,9 +319,20 @@ interface EngineAdapter {
302
319
  */
303
320
  velocityTolerance(epsilon: number, timestepSeconds: number): number;
304
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;
305
332
  /** The engine-neutral tuning knobs; both engines' option objects carry these names. */
306
333
  interface PredictorTuning {
307
334
  readonly maxPredictedBodies?: number;
335
+ readonly maxProxyBodies?: number;
308
336
  readonly epsilon?: number;
309
337
  readonly smoothingHalfLifeMs?: number;
310
338
  readonly smoothingSnapUnits?: number;
@@ -322,11 +350,24 @@ interface PredictionStats {
322
350
  /** Corrections whose values matched the local prediction within epsilon. */
323
351
  suppressed: number;
324
352
  /**
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.
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".
328
360
  */
329
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;
330
371
  /** Microseconds spent in the last rebase's re-steps. */
331
372
  lastResimMicros: number;
332
373
  /**
@@ -389,11 +430,19 @@ declare class Predictor {
389
430
  /** Seconds per step, fixed for the life of the world. */
390
431
  private timestepSeconds;
391
432
  private readonly bodies;
433
+ /** D71: kinematic proxies, keyed like `bodies`. The two maps are disjoint by construction. */
434
+ private readonly proxies;
392
435
  /** Physics-backed entity collections, in schema order. D50: not `readonly`, see `swapSchema`. */
393
436
  private collections;
394
437
  private readonly warned;
395
438
  /** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
396
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;
397
446
  /** Authority arrived since the last frame: rebase before free-running. */
398
447
  private authorityDirty;
399
448
  private accumulatorMs;
@@ -466,6 +515,11 @@ declare class Predictor {
466
515
  * `PredictedBody.desc` and re-deriving collider shapes from the new descriptors.
467
516
  */
468
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;
469
523
  get epsilon(): number;
470
524
  /** `true` once the engine is loaded and the local world exists. */
471
525
  get ready(): boolean;
@@ -509,8 +563,19 @@ declare class Predictor {
509
563
  * that is the lead, not this.
510
564
  */
511
565
  noteWriteApplied(stampTick: number, appliedTick: number): void;
512
- /** 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
+ */
513
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;
514
579
  /**
515
580
  * Is a correction's every value within the suppression tolerance of the prediction it judges?
516
581
  * Position (and rotation) channels compare against `epsilon` directly; velocity and angular
@@ -615,24 +680,77 @@ declare class Predictor {
615
680
  */
616
681
  predictedValues(desc: CollectionDesc, id: string, fields: readonly string[], atTick?: number): AnyRecord$2 | undefined;
617
682
  /**
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).
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`.
687
+ *
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.
622
692
  *
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.
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.
626
710
  */
627
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;
628
741
  private createBody;
629
742
  /**
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.
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.
634
752
  */
635
- private warnOverCap;
753
+ private warnCaps;
636
754
  private warnOnce;
637
755
  /**
638
756
  * The client's lead over authority, in ticks: a full round trip, rounded to the nearest tick
@@ -696,6 +814,11 @@ declare class Predictor {
696
814
  * local body of a collection, owned or not, run after the whole intent pass rather than
697
815
  * interleaved with it. It is what gives a non-owned predicted body its gravity on an engine
698
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.
699
822
  */
700
823
  private applyIntents;
701
824
  /** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
@@ -798,12 +921,22 @@ interface ClientPhysics2dOptions {
798
921
  */
799
922
  readonly settle?: Readonly<Record<string, ClientIntent2dHook>>;
800
923
  /**
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.
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`.
805
930
  */
806
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;
807
940
  /**
808
941
  * A body-field correction whose every value is within this tolerance of the local prediction
809
942
  * is *suppressed*: authority still applies, but it is not a misprediction. Positions compare
@@ -885,12 +1018,28 @@ interface ClientPhysicsOptions {
885
1018
  */
886
1019
  readonly intents?: Readonly<Record<string, ClientIntentHook>>;
887
1020
  /**
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.
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`.
892
1028
  */
893
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;
894
1043
  /**
895
1044
  * A body-field correction whose every value is within this tolerance of the local prediction
896
1045
  * is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
@@ -1369,8 +1518,18 @@ interface JoinOptions<S, Role extends string = string> {
1369
1518
  */
1370
1519
  readonly profile?: boolean;
1371
1520
  }
1372
- /** `joinRelay` options: a relay room has no schema, so there is no state and no RPC. */
1373
- 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;
1374
1533
  readonly room?: string;
1375
1534
  readonly role?: string;
1376
1535
  readonly name?: string;
@@ -1390,6 +1549,31 @@ interface JoinRelayOptions {
1390
1549
  type MessageTarget = 'all' | string | {
1391
1550
  readonly role: string;
1392
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>;
1393
1577
  /**
1394
1578
  * D65: the client's view of where its bytes went, by collection and field.
1395
1579
  *
@@ -1415,6 +1599,44 @@ interface RoomProfile {
1415
1599
  * a predicted body passes through them.
1416
1600
  */
1417
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;
1418
1640
  /** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
1419
1641
  declare const PREDICTION_EPSILON = 0.05;
1420
1642
  /**
@@ -1437,6 +1659,15 @@ interface PredictionStatus {
1437
1659
  readonly active: boolean;
1438
1660
  /** Is `collection[id]` currently simulated in the local world? */
1439
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;
1440
1671
  readonly stats: PredictionStats;
1441
1672
  }
1442
1673
  /**
@@ -1498,13 +1729,24 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
1498
1729
  requestOwnership(entity: string, id: string): Promise<boolean>;
1499
1730
  message(target: MessageTarget, bytes: Uint8Array): void;
1500
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;
1501
1736
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1502
1737
  /** Sends any pending owned writes immediately instead of at the next flush window. */
1503
1738
  flush(): void;
1504
1739
  leave(): void;
1505
1740
  }
1506
- /** What `joinRelay` returns: presence + the raw message channel, nothing else. */
1507
- 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> {
1508
1750
  readonly me: string;
1509
1751
  readonly id: string;
1510
1752
  /**
@@ -1519,6 +1761,10 @@ interface RelayRoom {
1519
1761
  readonly clients: readonly PresenceRecord[];
1520
1762
  message(target: MessageTarget, bytes: Uint8Array): void;
1521
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;
1522
1768
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
1523
1769
  leave(): void;
1524
1770
  }
@@ -1805,6 +2051,25 @@ declare class RenderStore {
1805
2051
  attachPredictor(predictor: Predictor): void;
1806
2052
  /** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
1807
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;
1808
2073
  /** Is `collection[id]` visible at the render clock? */
1809
2074
  has(desc: CollectionDesc, id: string): boolean;
1810
2075
  /** The authoritative owner — ownership is fact, not a rendered value. */
@@ -1906,8 +2171,11 @@ declare class Session {
1906
2171
  * whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
1907
2172
  * swapped session announces its *current* hash when it reconnects rather than the one it was
1908
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.
1909
2177
  */
1910
- private schema;
2178
+ schema: AnySchema | undefined;
1911
2179
  /** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
1912
2180
  schemaSwaps: number;
1913
2181
  private readonly transport;
@@ -1969,6 +2237,24 @@ declare class Session {
1969
2237
  * `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
1970
2238
  */
1971
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
+ };
1972
2258
  private cancelFlush;
1973
2259
  private cancelPing;
1974
2260
  private cancelRetry;
@@ -2073,6 +2359,18 @@ declare class Session {
2073
2359
  private onError;
2074
2360
  private onPong;
2075
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;
2076
2374
  private startTimers;
2077
2375
  /**
2078
2376
  * The write batcher: one window per animation frame in a browser, or per `writeIntervalMs`
@@ -2094,6 +2392,13 @@ declare class Session {
2094
2392
  private rejectPending;
2095
2393
  message(target: MessageTarget, bytes: Uint8Array): void;
2096
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;
2097
2402
  /**
2098
2403
  * D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
2099
2404
  * so it is not on the `Room` type and a game cannot call it.
@@ -2279,6 +2584,6 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
2279
2584
  * channel. A separate entry rather than a schema-less `joinRoom` overload, because everything a
2280
2585
  * `Room` promises about state would be a lie here.
2281
2586
  */
2282
- 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>>;
2283
2588
 
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 };
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 };