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