@irtio/client 0.7.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.
@@ -11,6 +11,7 @@ function emptyPredictionStats() {
11
11
  resimSteps: 0,
12
12
  rebases: 0,
13
13
  snaps: 0,
14
+ leadClamped: 0,
14
15
  suppressed: 0,
15
16
  overCap: 0,
16
17
  proxies: 0,
@@ -5,7 +5,7 @@ import {
5
5
  RESIM_DEPTH,
6
6
  SMOOTHING_HALF_LIFE_MS,
7
7
  SMOOTHING_SNAP_UNITS
8
- } from "./chunk-XWVXZRBS.js";
8
+ } from "./chunk-5Z4DHUA3.js";
9
9
 
10
10
  // src/predictor.ts
11
11
  var MAX_FREE_STEPS_PER_FRAME = 5;
@@ -133,6 +133,7 @@ var Predictor = class _Predictor {
133
133
  resimSteps: 0,
134
134
  rebases: 0,
135
135
  snaps: 0,
136
+ leadClamped: 0,
136
137
  suppressed: 0,
137
138
  overCap: 0,
138
139
  proxies: 0,
@@ -951,8 +952,8 @@ var Predictor = class _Predictor {
951
952
  * written, so plain state holds exactly what the server said), then re-step the world by the
952
953
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
953
954
  * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
954
- * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
955
- * authority and counts (`stats.snaps`).
955
+ * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead re-steps that
956
+ * many and counts (`stats.leadClamped`, bugs.md #71).
956
957
  */
957
958
  rebase() {
958
959
  if (!this.worldReady) return;
@@ -966,7 +967,9 @@ var Predictor = class _Predictor {
966
967
  const k = key(entry.desc.name, entry.id);
967
968
  this.predictedTicks.set(k, Math.max(this.authorityTick, this.authorityTicks.get(k) ?? 0));
968
969
  }
969
- const lead = this.leadTicks();
970
+ const wanted = this.leadTicks();
971
+ const lead = Math.min(wanted, MAX_LEAD);
972
+ if (wanted > MAX_LEAD) this.stats.leadClamped++;
970
973
  if (this.lastLead !== void 0 && lead !== this.lastLead && this.gapMeasured) {
971
974
  const delta = lead - this.lastLead;
972
975
  this.stampGap = Math.min(RESIM_DEPTH, Math.max(0, this.stampGap + delta));
@@ -975,10 +978,6 @@ var Predictor = class _Predictor {
975
978
  }
976
979
  this.lastLead = lead;
977
980
  this.headTick = this.authorityTick;
978
- if (lead > MAX_LEAD) {
979
- this.stats.snaps++;
980
- return;
981
- }
982
981
  const replays = /* @__PURE__ */ new Map();
983
982
  for (const entry of this.bodies.values()) {
984
983
  if (!entry.owned) continue;
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, MessageChannels, 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
 
@@ -345,8 +346,21 @@ interface PredictionStats {
345
346
  resimSteps: number;
346
347
  /** Rebase passes (one per authoritative arrival batch). */
347
348
  rebases: number;
348
- /** 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
+ */
349
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;
350
364
  /** Corrections whose values matched the local prediction within epsilon. */
351
365
  suppressed: number;
352
366
  /**
@@ -788,8 +802,8 @@ declare class Predictor {
788
802
  * written, so plain state holds exactly what the server said), then re-step the world by the
789
803
  * client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
790
804
  * the newest buffered unjudged write stamped at or before it, or the baseline (the newest
791
- * judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead snaps to
792
- * 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).
793
807
  */
794
808
  private rebase;
795
809
  /**
@@ -826,6 +840,126 @@ declare class Predictor {
826
840
  private isF32;
827
841
  }
828
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
+
829
963
  /**
830
964
  * The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
831
965
  * option type, the lazy `matter-js` load, and the adapter that is the only place in the client
@@ -900,6 +1034,11 @@ type ClientIntent2dHook = {
900
1034
  * Every function here should be the very export the room config imports.
901
1035
  */
902
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';
903
1042
  /** Must equal the room config's gravity, in matter's own convention (y is down). */
904
1043
  readonly gravity: ClientVector2;
905
1044
  /** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
@@ -1505,7 +1644,7 @@ interface JoinOptions<S, Role extends string = string> {
1505
1644
  * take different functions; passing both is an error at join. The wire carries no engine
1506
1645
  * name, so passing the one that does not match the room is a game bug the client cannot see.
1507
1646
  */
1508
- readonly physics2d?: ClientPhysics2dOptions;
1647
+ readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions;
1509
1648
  /** @internal */
1510
1649
  readonly transport?: Transport;
1511
1650
  /** @internal */
@@ -1934,6 +2073,230 @@ declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S>
1934
2073
  } & {
1935
2074
  readonly identity?: boolean;
1936
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;
1937
2300
 
1938
2301
  /**
1939
2302
  * The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
@@ -2135,7 +2498,7 @@ interface SessionOptions {
2135
2498
  /** The shared world-builder half the client predicts with (D22 part 2). */
2136
2499
  readonly physics?: ClientPhysicsOptions | undefined;
2137
2500
  /** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
2138
- readonly physics2d?: ClientPhysics2dOptions | undefined;
2501
+ readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions | undefined;
2139
2502
  readonly transport?: Transport | undefined;
2140
2503
  readonly scheduler?: Scheduler | undefined;
2141
2504
  readonly onFrame?: FrameHook | undefined;
@@ -2586,4 +2949,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
2586
2949
  */
2587
2950
  declare function joinRelay<S extends AnySchema = never>(options?: JoinRelayOptions<S extends AnySchema ? S : AnySchema>): Promise<RelayRoom<S>>;
2588
2951
 
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 };
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 };
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  SMOOTHING_HALF_LIFE_MS,
9
9
  SMOOTHING_SNAP_UNITS,
10
10
  emptyPredictionStats
11
- } from "./chunk-XWVXZRBS.js";
11
+ } from "./chunk-5Z4DHUA3.js";
12
12
 
13
13
  // src/index.ts
14
14
  import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
@@ -1117,7 +1117,7 @@ var Session = class {
1117
1117
  const physics2d = options.physics2d;
1118
1118
  if (physics && hasPhysics) {
1119
1119
  this.predictionRequested = true;
1120
- void import("./physics-RT5T36P5.js").then(({ PhysicsPredictor }) => {
1120
+ void import("./physics-4SGKNBAC.js").then(({ PhysicsPredictor }) => {
1121
1121
  if (this.left) return;
1122
1122
  attach(
1123
1123
  new PhysicsPredictor(
@@ -1130,15 +1130,32 @@ var Session = class {
1130
1130
  )
1131
1131
  );
1132
1132
  });
1133
- } else if (physics2d && hasPhysics) {
1133
+ } else if (physics2d !== void 0 && physics2d.engine === "rapier2d" && hasPhysics) {
1134
+ const rapier2d = physics2d;
1134
1135
  this.predictionRequested = true;
1135
- void import("./physics2d-HOFMWPZV.js").then(({ Physics2dPredictor }) => {
1136
+ void import("./physics-rapier2d-TBOAJMVM.js").then(({ Rapier2dPredictor }) => {
1137
+ if (this.left) return;
1138
+ attach(
1139
+ new Rapier2dPredictor(
1140
+ this.ext,
1141
+ this.store,
1142
+ rapier2d,
1143
+ () => this.me,
1144
+ () => this.rtt,
1145
+ () => this.tickIntervalMs
1146
+ )
1147
+ );
1148
+ });
1149
+ } else if (physics2d !== void 0 && physics2d.engine !== "rapier2d" && hasPhysics) {
1150
+ const matter2d = physics2d;
1151
+ this.predictionRequested = true;
1152
+ void import("./physics2d-CNSEWKP4.js").then(({ Physics2dPredictor }) => {
1136
1153
  if (this.left) return;
1137
1154
  attach(
1138
1155
  new Physics2dPredictor(
1139
1156
  this.ext,
1140
1157
  this.store,
1141
- physics2d,
1158
+ matter2d,
1142
1159
  () => this.me,
1143
1160
  () => this.rtt,
1144
1161
  () => this.tickIntervalMs
@@ -2191,6 +2208,233 @@ function isRoomFull(err) {
2191
2208
  const message = err instanceof Error ? err.message : String(err);
2192
2209
  return message.startsWith("E_ROOM_FULL");
2193
2210
  }
2211
+ async function findPublic(project, options = {}) {
2212
+ const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
2213
+ const fetchImpl = options.fetch ?? fetch;
2214
+ let res;
2215
+ try {
2216
+ res = await fetchImpl(`${controlUrl}/match`, {
2217
+ method: "POST",
2218
+ headers: { "content-type": "application/json" },
2219
+ body: JSON.stringify({
2220
+ project,
2221
+ mode: "public",
2222
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2223
+ ...options.identity !== void 0 ? { identity: options.identity } : {},
2224
+ ...options.exclude !== void 0 ? { exclude: options.exclude } : {}
2225
+ })
2226
+ });
2227
+ } catch (err) {
2228
+ throw new MatchError(
2229
+ "E_MATCH_UNREACHABLE",
2230
+ `cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
2231
+ );
2232
+ }
2233
+ const text = await res.text().catch(() => "");
2234
+ let body = {};
2235
+ try {
2236
+ body = JSON.parse(text);
2237
+ } catch {
2238
+ }
2239
+ if (!res.ok) {
2240
+ throw new MatchError(
2241
+ typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
2242
+ typeof body.message === "string" ? body.message : `match failed with status ${res.status}`
2243
+ );
2244
+ }
2245
+ if (body.status !== "matched" || typeof body.room !== "string") {
2246
+ throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
2247
+ }
2248
+ return {
2249
+ room: body.room,
2250
+ queue: typeof body.queue === "string" ? body.queue : "default",
2251
+ size: typeof body.size === "number" ? body.size : 0,
2252
+ created: body.created === true
2253
+ };
2254
+ }
2255
+ async function joinPublic(schema, options = {}) {
2256
+ const project = schema.project;
2257
+ const key = options.key ?? (typeof project === "string" ? project : void 0);
2258
+ if (key === void 0 || key === "") {
2259
+ throw new MatchError(
2260
+ "E_MATCH_NO_PROJECT",
2261
+ "joinPublic needs a project key: build your schema with `irtio` so it carries one, or pass { key }"
2262
+ );
2263
+ }
2264
+ const identity = options.identity === true ? new Identity({
2265
+ project: key,
2266
+ controlUrl: options.controlUrl,
2267
+ fetch: options.fetch
2268
+ }) : void 0;
2269
+ const ask = async (exclude) => findPublic(key, {
2270
+ ...options.queue !== void 0 ? { queue: options.queue } : {},
2271
+ ...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
2272
+ ...options.fetch !== void 0 ? { fetch: options.fetch } : {},
2273
+ ...identity !== void 0 ? { identity: await identity.ensure() } : {},
2274
+ ...exclude !== void 0 ? { exclude } : {}
2275
+ });
2276
+ const ticket = await ask();
2277
+ try {
2278
+ return await joinRoom(schema, { ...options, room: ticket.room });
2279
+ } catch (err) {
2280
+ if (!isRetryablePublicJoin(err)) throw err;
2281
+ const second = await ask(ticket.room);
2282
+ return joinRoom(schema, { ...options, room: second.room });
2283
+ }
2284
+ }
2285
+ function isRetryablePublicJoin(err) {
2286
+ const message = err instanceof Error ? err.message : String(err);
2287
+ return message.startsWith("E_ROOM_FULL") || message.startsWith("E_ROOM_NOT_FOUND");
2288
+ }
2289
+
2290
+ // src/lobby.ts
2291
+ var LOBBY_STATE = "irtLobby";
2292
+ var LOBBY_MEMBERS = "irtLobbyMembers";
2293
+ var LOBBY_POLL_MS = 120;
2294
+ function hasLobby(room) {
2295
+ const state = room?.state;
2296
+ return typeof state === "object" && state !== null && state[LOBBY_STATE] !== void 0 && state[LOBBY_MEMBERS] !== void 0;
2297
+ }
2298
+ function lobbyOf(room, options = {}) {
2299
+ if (!hasLobby(room)) return void 0;
2300
+ const r = room;
2301
+ const singleton = () => r.state[LOBBY_STATE] ?? {};
2302
+ const members = () => r.state[LOBBY_MEMBERS];
2303
+ const players = () => r.clients.map((c) => ({
2304
+ clientId: c.clientId,
2305
+ ready: members().get(c.clientId)?.ready === true,
2306
+ connected: c.connected !== false,
2307
+ me: c.clientId === r.me
2308
+ }));
2309
+ const signature = () => {
2310
+ const s = singleton();
2311
+ let out = `${s.phase ?? ""}|${s.public === true ? 1 : 0}|${s.readyUi === true ? 1 : 0}|${s.capacity ?? 0}`;
2312
+ for (const p of players()) out += `|${p.clientId}${p.ready ? 1 : 0}${p.connected ? 1 : 0}`;
2313
+ return out;
2314
+ };
2315
+ return {
2316
+ get phase() {
2317
+ return singleton().phase === "started" ? "started" : "lobby";
2318
+ },
2319
+ get players() {
2320
+ return players();
2321
+ },
2322
+ get public() {
2323
+ return singleton().public === true;
2324
+ },
2325
+ get readyUi() {
2326
+ return singleton().readyUi === true;
2327
+ },
2328
+ get capacity() {
2329
+ return singleton().capacity ?? 0;
2330
+ },
2331
+ ready(value) {
2332
+ const mine = members().get(r.me);
2333
+ if (!mine) return;
2334
+ mine.ready = value === true;
2335
+ r.flush();
2336
+ },
2337
+ ...options.setPublic !== void 0 ? { setPublic: options.setPublic } : {},
2338
+ on(event, cb) {
2339
+ if (event !== "change") return () => {
2340
+ };
2341
+ let last = signature();
2342
+ const sample = () => {
2343
+ const next = signature();
2344
+ if (next === last) return;
2345
+ last = next;
2346
+ cb();
2347
+ };
2348
+ const offClients = r.on("clients", sample);
2349
+ const timer = setInterval(sample, LOBBY_POLL_MS);
2350
+ timer.unref?.();
2351
+ return () => {
2352
+ offClients();
2353
+ clearInterval(timer);
2354
+ };
2355
+ }
2356
+ };
2357
+ }
2358
+ function attachable(room, view) {
2359
+ return {
2360
+ get id() {
2361
+ return room.id;
2362
+ },
2363
+ get link() {
2364
+ return room.link;
2365
+ },
2366
+ get status() {
2367
+ return room.status;
2368
+ },
2369
+ get clients() {
2370
+ return room.clients;
2371
+ },
2372
+ get maxClients() {
2373
+ return room.maxClients;
2374
+ },
2375
+ get rtt() {
2376
+ return room.rtt;
2377
+ },
2378
+ on: (event, cb) => room.on(event, cb),
2379
+ ...view !== void 0 ? { lobby: view } : {}
2380
+ };
2381
+ }
2382
+ function attachLobby(element, schema, options = {}) {
2383
+ let detachRoom;
2384
+ let busy = false;
2385
+ const fail = (err) => {
2386
+ element.removeAttribute("searching");
2387
+ if (options.onError) {
2388
+ options.onError(err);
2389
+ return;
2390
+ }
2391
+ queueMicrotask(() => {
2392
+ throw err;
2393
+ });
2394
+ };
2395
+ const landed = (room) => {
2396
+ element.removeAttribute("searching");
2397
+ const view = lobbyOf(room, {
2398
+ ...options.setPublic !== void 0 ? { setPublic: (value) => options.setPublic?.(room, value) } : {}
2399
+ });
2400
+ detachRoom = element.attach(
2401
+ attachable(room, view)
2402
+ );
2403
+ options.onRoom?.(room);
2404
+ };
2405
+ const run = async (make) => {
2406
+ if (busy) return;
2407
+ busy = true;
2408
+ try {
2409
+ landed(await make());
2410
+ } catch (err) {
2411
+ fail(err);
2412
+ } finally {
2413
+ busy = false;
2414
+ }
2415
+ };
2416
+ const onPrivate = () => {
2417
+ void run(() => joinRoom(schema, { ...options.join ?? {} }));
2418
+ };
2419
+ const onQuickMatch = (event) => {
2420
+ const detail = event.detail;
2421
+ element.setAttribute("searching", "");
2422
+ void run(
2423
+ () => joinPublic(schema, {
2424
+ ...options.join ?? {},
2425
+ ...options.match ?? {},
2426
+ ...detail?.queue !== void 0 ? { queue: detail.queue } : {}
2427
+ })
2428
+ );
2429
+ };
2430
+ element.addEventListener("private", onPrivate);
2431
+ element.addEventListener("quickmatch", onQuickMatch);
2432
+ return () => {
2433
+ element.removeEventListener("private", onPrivate);
2434
+ element.removeEventListener("quickmatch", onQuickMatch);
2435
+ detachRoom?.();
2436
+ };
2437
+ }
2194
2438
 
2195
2439
  // src/voice.ts
2196
2440
  import {
@@ -2693,6 +2937,9 @@ export {
2693
2937
  INTERNAL_VOICE_TRACKS,
2694
2938
  Identity,
2695
2939
  IdentityError,
2940
+ LOBBY_MEMBERS,
2941
+ LOBBY_POLL_MS,
2942
+ LOBBY_STATE,
2696
2943
  MAX_IDENTITY_RETRY_WAIT_MS,
2697
2944
  MAX_PREDICTED_BODIES,
2698
2945
  MAX_PROXY_BODIES,
@@ -2704,14 +2951,20 @@ export {
2704
2951
  SMOOTHING_HALF_LIFE_MS,
2705
2952
  SMOOTHING_SNAP_UNITS,
2706
2953
  Session,
2954
+ attachLobby,
2955
+ attachable,
2707
2956
  createParty,
2708
2957
  defaultScheduler,
2709
2958
  findMatch,
2959
+ findPublic,
2960
+ hasLobby,
2710
2961
  identityStorageKey,
2962
+ joinPublic,
2711
2963
  joinRelay,
2712
2964
  joinRoom,
2713
2965
  joinVoice,
2714
2966
  linkForUrl,
2967
+ lobbyOf,
2715
2968
  matchRoom,
2716
2969
  resolveUrl,
2717
2970
  roomIdFrom,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-DABQDR3S.js";
4
- import "./chunk-XWVXZRBS.js";
3
+ } from "./chunk-XFD6WYQW.js";
4
+ import "./chunk-5Z4DHUA3.js";
5
5
 
6
6
  // src/physics.ts
7
7
  function readPose(body, into) {
@@ -0,0 +1,186 @@
1
+ import {
2
+ Predictor
3
+ } from "./chunk-XFD6WYQW.js";
4
+ import "./chunk-5Z4DHUA3.js";
5
+
6
+ // src/physics-rapier2d.ts
7
+ import {
8
+ angleFrom2d,
9
+ applyChannel2d,
10
+ channelOf2d
11
+ } from "@irtio/schema";
12
+ var engine;
13
+ var loading;
14
+ async function loadEngine() {
15
+ if (engine) return engine;
16
+ loading ??= (async () => {
17
+ const mod = await import("@dimforge/rapier2d-compat");
18
+ const ns = mod.default ?? mod;
19
+ await ns.init();
20
+ engine = ns;
21
+ return ns;
22
+ })();
23
+ return loading;
24
+ }
25
+ var Rapier2dAdapter = class {
26
+ constructor(options) {
27
+ this.options = options;
28
+ }
29
+ options;
30
+ engineName = "@dimforge/rapier2d-compat";
31
+ optionName = "physics2d";
32
+ rapier;
33
+ world;
34
+ timestepSeconds = 1 / 30;
35
+ /** Reused by `readPose`, which runs per body per step and must not allocate. */
36
+ state = {
37
+ x: 0,
38
+ y: 0,
39
+ angle: 0,
40
+ vx: 0,
41
+ vy: 0,
42
+ angularVelocity: 0
43
+ };
44
+ /** `applyChannel2d`'s target shape, likewise reused. */
45
+ channels = { x: 0, y: 0, qz: 0, qw: 1, vx: 0, vy: 0, wz: 0 };
46
+ /** `moveKinematic`'s translation argument, reused: it runs per proxy per step (D71). */
47
+ moveTarget = { x: 0, y: 0 };
48
+ get timestep() {
49
+ return this.options.timestep;
50
+ }
51
+ async start(timestepSeconds) {
52
+ const rapier = await loadEngine();
53
+ this.rapier = rapier;
54
+ this.timestepSeconds = timestepSeconds;
55
+ const world = new rapier.World({ x: this.options.gravity.x, y: this.options.gravity.y });
56
+ world.timestep = timestepSeconds;
57
+ if (this.options.setup) this.options.setup(world, rapier);
58
+ this.world = world;
59
+ }
60
+ free() {
61
+ this.world?.free();
62
+ this.world = void 0;
63
+ }
64
+ hasFactory(collection) {
65
+ return this.options.bodies?.[collection] !== void 0;
66
+ }
67
+ createBody(desc, id, record, _warn) {
68
+ const world = this.world;
69
+ const rapier = this.rapier;
70
+ const factory = this.options.bodies?.[desc.name];
71
+ if (!world || !rapier || !factory) return void 0;
72
+ const spec = factory(rapier, record, id);
73
+ if (!spec || !spec.body) return void 0;
74
+ const body = world.createRigidBody(spec.body);
75
+ for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
76
+ return body;
77
+ }
78
+ removeBody(body) {
79
+ this.world?.removeRigidBody(body);
80
+ }
81
+ /**
82
+ * D71: a proxy is a `KinematicPositionBased` body, exactly as in 3D — driven by the position it
83
+ * is told to be at next, never by a force, an impulse or a contact, with Rapier deriving the
84
+ * velocity its contacts see from the move itself. The body is *switched* rather than built
85
+ * kinematic, so its collider and mass properties are the ones the shared factory produced.
86
+ */
87
+ makeKinematic(handle) {
88
+ const rapier = this.rapier;
89
+ if (!rapier) return;
90
+ handle.setBodyType(rapier.RigidBodyType.KinematicPositionBased, true);
91
+ }
92
+ moveKinematic(handle, pose) {
93
+ const body = handle;
94
+ const to = this.moveTarget;
95
+ to.x = pose.t.x;
96
+ to.y = pose.t.y;
97
+ body.setNextKinematicTranslation(to);
98
+ body.setNextKinematicRotation(angleFrom2d(pose.r.z, pose.r.w));
99
+ }
100
+ step() {
101
+ this.world?.step();
102
+ }
103
+ /**
104
+ * Server record → body state, through `applyChannel2d` so the plane-to-channel mapping stays
105
+ * written once, and in `Rapier2dRuntime.applyRecordToBody`'s order — translation, rotation,
106
+ * linear velocity, spin — so the two sides write the same body the same way. Every setter takes
107
+ * `wakeUp = true`: a rebase writes state a sleeping body would otherwise ignore.
108
+ */
109
+ applyRecord(handle, channels, record) {
110
+ const body = handle;
111
+ const t = this.channels;
112
+ const pos = body.translation();
113
+ const vel = body.linvel();
114
+ const angle = body.rotation();
115
+ t.x = pos.x;
116
+ t.y = pos.y;
117
+ t.qz = Math.sin(angle / 2);
118
+ t.qw = Math.cos(angle / 2);
119
+ t.vx = vel.x;
120
+ t.vy = vel.y;
121
+ t.wz = body.angvel();
122
+ for (const [channel, field] of channels) {
123
+ const raw = record[field];
124
+ if (typeof raw !== "number") continue;
125
+ applyChannel2d(channel, raw, t);
126
+ }
127
+ body.setTranslation({ x: t.x, y: t.y }, true);
128
+ body.setRotation(angleFrom2d(t.qz, t.qw), true);
129
+ body.setLinvel({ x: t.vx, y: t.vy }, true);
130
+ body.setAngvel(t.wz, true);
131
+ }
132
+ /**
133
+ * Body state → a 3D pose, through `channelOf2d`: `t = (x, y, 0)`, `r` a quaternion about Z,
134
+ * `v = (vx, vy, 0)`, `w = (0, 0, spin)`.
135
+ */
136
+ readPose(handle, into) {
137
+ const body = handle;
138
+ const s = this.state;
139
+ const pos = body.translation();
140
+ const vel = body.linvel();
141
+ s.x = pos.x;
142
+ s.y = pos.y;
143
+ s.angle = body.rotation();
144
+ s.vx = vel.x;
145
+ s.vy = vel.y;
146
+ s.angularVelocity = body.angvel();
147
+ const b = s;
148
+ into.t.x = channelOf2d("x", b);
149
+ into.t.y = channelOf2d("y", b);
150
+ into.t.z = channelOf2d("z", b);
151
+ into.r.x = channelOf2d("qx", b);
152
+ into.r.y = channelOf2d("qy", b);
153
+ into.r.z = channelOf2d("qz", b);
154
+ into.r.w = channelOf2d("qw", b);
155
+ into.v.x = channelOf2d("vx", b);
156
+ into.v.y = channelOf2d("vy", b);
157
+ into.v.z = channelOf2d("vz", b);
158
+ into.w.x = channelOf2d("wx", b);
159
+ into.w.y = channelOf2d("wy", b);
160
+ into.w.z = channelOf2d("wz", b);
161
+ }
162
+ applyIntent(collection, body, instance) {
163
+ const hook = this.options.intents?.[collection];
164
+ const world = this.world;
165
+ const rapier = this.rapier;
166
+ if (!hook || !world || !rapier) return;
167
+ hook(body, instance, rapier, world, this.timestepSeconds);
168
+ }
169
+ // No `settle`. The world applies gravity; see the module docblock.
170
+ /** Rapier velocities are per second, so one tick of error is `epsilon` when `dv = eps / dt`. */
171
+ velocityTolerance(epsilon, timestepSeconds) {
172
+ return epsilon / timestepSeconds;
173
+ }
174
+ };
175
+ var Rapier2dPredictor = class extends Predictor {
176
+ constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
177
+ super(ext, store, new Rapier2dAdapter(options), options, meOf, rttOf, tickIntervalOf, log);
178
+ }
179
+ };
180
+ function createRapier2dAdapter(options) {
181
+ return new Rapier2dAdapter(options);
182
+ }
183
+ export {
184
+ Rapier2dPredictor,
185
+ createRapier2dAdapter
186
+ };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Predictor
3
- } from "./chunk-DABQDR3S.js";
4
- import "./chunk-XWVXZRBS.js";
3
+ } from "./chunk-XFD6WYQW.js";
4
+ import "./chunk-5Z4DHUA3.js";
5
5
 
6
6
  // src/physics2d.ts
7
7
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/client",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "irtio client SDK: joinRoom, owned-write batching, corrections, typed RPCs, presence, reconnection",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,14 +20,18 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "mediasoup-client": "^3.23.1",
23
- "@irtio/protocol": "0.7.0",
24
- "@irtio/schema": "0.7.0"
23
+ "@irtio/protocol": "0.8.0",
24
+ "@irtio/schema": "0.8.0"
25
25
  },
26
26
  "peerDependencies": {
27
+ "@dimforge/rapier2d-compat": ">=0.20.0",
27
28
  "@dimforge/rapier3d-compat": ">=0.20.0",
28
29
  "matter-js": ">=0.20.0"
29
30
  },
30
31
  "peerDependenciesMeta": {
32
+ "@dimforge/rapier2d-compat": {
33
+ "optional": true
34
+ },
31
35
  "@dimforge/rapier3d-compat": {
32
36
  "optional": true
33
37
  },
@@ -39,6 +43,7 @@
39
43
  "@dimforge/rapier3d-compat": "0.20.0",
40
44
  "@types/matter-js": "0.20.2",
41
45
  "matter-js": "0.20.0",
46
+ "@dimforge/rapier2d-compat": "0.20.0",
42
47
  "@irtio/sfu": "0.0.0"
43
48
  },
44
49
  "scripts": {