@irtio/client 0.1.0 → 0.2.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/chunk-AJHN3YF6.js +562 -0
- package/dist/index.d.ts +589 -134
- package/dist/index.js +432 -475
- package/dist/physics-BBFSQEYL.js +583 -0
- package/package.json +14 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,434 @@
|
|
|
1
|
-
import { ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy
|
|
1
|
+
import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, ReadonlyCollection, RoleOf, VisibleKeys, SchemaDefs, EntityDef, DeepReadonly, InferFields, Owned, SingletonDef, ClientImplementations, ClientRpcs, SchemaRpc, ClientCallProxy } from '@irtio/schema';
|
|
2
|
+
import RAPIER from '@dimforge/rapier3d-compat';
|
|
2
3
|
import { PresenceRecord } from '@irtio/protocol';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* The client's state layer.
|
|
7
|
+
*
|
|
8
|
+
* One plain `PlainState` (decoded from the join snapshot, advanced by every `DELTA`) with two
|
|
9
|
+
* views over it:
|
|
10
|
+
*
|
|
11
|
+
* - **owned instances** → the `track()` proxy for that record, so a write normalizes exactly like
|
|
12
|
+
* the server would (f32 quantized, ranges and string sizes enforced by a throw) and marks a
|
|
13
|
+
* dirty set the flush window turns into one `WRITE`;
|
|
14
|
+
* - **everything else** → `frozenProxy`, which ignores writes and warns once with the ownership
|
|
15
|
+
* hint.
|
|
16
|
+
*
|
|
17
|
+
* Which one you get is decided per `get()`, so an ownership change needs no bookkeeping — but the
|
|
18
|
+
* object identity is not stable across it, which is why `state.x.get(id)` must be re-read after a
|
|
19
|
+
* grab (documented in the README).
|
|
7
20
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
21
|
+
* The server-wins rule lives here too: a `DELTA`'s update ops are dropped field-wise for
|
|
22
|
+
* instances this client owns (adds, removes and owner changes still apply), while a `CORRECT`
|
|
23
|
+
* applies in full and clears the matching local dirty marks.
|
|
11
24
|
*/
|
|
12
25
|
|
|
26
|
+
type AnyRecord$3 = Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* Resimulation depth cap (D19): how many flushed-but-unjudged `WRITE`s the client retains for
|
|
29
|
+
* replay after a `CORRECT`. A correction older than the retained window snaps everything it
|
|
30
|
+
* corrects and counts it (`snapped`); buffer memory is bounded by the same window.
|
|
31
|
+
*/
|
|
32
|
+
declare const RESIM_DEPTH = 20;
|
|
33
|
+
/** One correction op, as the `'correct'` event sees it. */
|
|
34
|
+
interface CorrectionOp {
|
|
35
|
+
readonly collection: string;
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly fields: readonly string[];
|
|
38
|
+
readonly patch: AnyRecord$3;
|
|
39
|
+
/** The local (predicted) values of `fields` right before the snap — what `patch` overrode. */
|
|
40
|
+
readonly previous: AnyRecord$3;
|
|
41
|
+
readonly tick: number;
|
|
42
|
+
/**
|
|
43
|
+
* The client write tick the server judged (D19), absent against a pre-week-8 server. Local
|
|
44
|
+
* writes newer than it were re-applied on top of the correction.
|
|
45
|
+
*/
|
|
46
|
+
readonly clientTick?: number;
|
|
47
|
+
/** How many pending local writes were re-applied over this correction's values. */
|
|
48
|
+
readonly replayed: number;
|
|
49
|
+
/** `true` when the correction outran the resim window and everything it named snapped. */
|
|
50
|
+
readonly snapped: boolean;
|
|
51
|
+
}
|
|
52
|
+
declare class ClientStore {
|
|
53
|
+
readonly ext: AnySchema;
|
|
54
|
+
/** Reads the current client id — it is not known until the first `WELCOME`. */
|
|
55
|
+
private readonly meOf;
|
|
56
|
+
plain: PlainState;
|
|
57
|
+
private tracked;
|
|
58
|
+
private readonly descs;
|
|
59
|
+
private readonly frozen;
|
|
60
|
+
/** The object handed out as `room.state`; identity survives a resync. */
|
|
61
|
+
readonly view: AnyRecord$3;
|
|
62
|
+
/** Flushed-but-unjudged writes, oldest first, at most `RESIM_DEPTH` entries (D19). */
|
|
63
|
+
private readonly pendingWrites;
|
|
64
|
+
/** The newest write tick evicted from `pendingWrites` — corrections older than it must snap. */
|
|
65
|
+
private evictedThroughTick;
|
|
66
|
+
/**
|
|
67
|
+
* The newest fields to leave `pendingWrites` per instance — judged into a correction, or
|
|
68
|
+
* evicted past the resim depth. This is the intent in force at the *start* of a resim window:
|
|
69
|
+
* the server holds a write's values until the next `WRITE` replaces them, so a replay that
|
|
70
|
+
* walks the window tick by tick starts from here, not from the newest local value.
|
|
71
|
+
*/
|
|
72
|
+
private readonly baselineIntents;
|
|
73
|
+
constructor(ext: AnySchema,
|
|
74
|
+
/** Reads the current client id — it is not known until the first `WELCOME`. */
|
|
75
|
+
meOf: () => string);
|
|
76
|
+
loadSnapshot(bytes: Uint8Array): void;
|
|
77
|
+
/**
|
|
78
|
+
* Captures the field values of every pending owned write, keyed by collection then id then
|
|
79
|
+
* field name — call this **before** `loadSnapshot` on a resync, or the edit is gone once the
|
|
80
|
+
* old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
|
|
81
|
+
*/
|
|
82
|
+
capturePendingWrites(): Map<string, Map<string, AnyRecord$3>>;
|
|
83
|
+
/**
|
|
84
|
+
* Writes a `capturePendingWrites` snapshot into the freshly loaded `plain` (still the raw
|
|
85
|
+
* decoded values — no validation, they were already validated at the time of the original
|
|
86
|
+
* local write) and marks every owned field dirty so the next flush resends the real edit, not
|
|
87
|
+
* whatever the resync snapshot happened to carry for that field.
|
|
88
|
+
*/
|
|
89
|
+
applyPendingWrites(pending: Map<string, Map<string, AnyRecord$3>>): void;
|
|
90
|
+
private buildView;
|
|
91
|
+
/** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
|
|
92
|
+
instance(desc: CollectionDesc, id: string): unknown;
|
|
93
|
+
private freeze;
|
|
94
|
+
private entityHint;
|
|
95
|
+
private singletonHint;
|
|
96
|
+
/**
|
|
97
|
+
* Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
|
|
98
|
+
* (server wins only through `CORRECT`); adds, removes and owner changes always apply.
|
|
99
|
+
*/
|
|
100
|
+
applyServerDelta(delta: Delta): void;
|
|
101
|
+
/**
|
|
102
|
+
* The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
|
|
103
|
+
* client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
|
|
104
|
+
* `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
|
|
105
|
+
* Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
|
|
106
|
+
* write is clamped) still wins through the snap+replay path.
|
|
107
|
+
*/
|
|
108
|
+
private replayOverAddEchoes;
|
|
109
|
+
private withoutOwnedUpdates;
|
|
110
|
+
/**
|
|
111
|
+
* An `add` for an instance this client will own, with its unflushed local field values folded
|
|
112
|
+
* back in. Returns `op` unchanged when there is nothing to preserve.
|
|
113
|
+
*/
|
|
114
|
+
private preserveLocalWrites;
|
|
115
|
+
/**
|
|
116
|
+
* Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
|
|
117
|
+
* write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
|
|
118
|
+
* the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
|
|
119
|
+
* dirty mark survives so the next flush re-sends the *local* value, not the server's).
|
|
120
|
+
*
|
|
121
|
+
* Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
|
|
122
|
+
* `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
|
|
123
|
+
* and the local dirty marks it supersedes are cleared.
|
|
124
|
+
*/
|
|
125
|
+
applyCorrection(delta: Delta, clientTick?: number): CorrectionOp[];
|
|
126
|
+
private desc;
|
|
127
|
+
/** The raw plain collection — authoritative values plus local writes (`track()` writes through). */
|
|
128
|
+
plainCollection(name: string): EntityCollection;
|
|
129
|
+
/**
|
|
130
|
+
* The flushed-but-unjudged write patches for one instance, oldest first, each keyed by the
|
|
131
|
+
* tick its `WRITE` was stamped with — for a physics entity these are exactly its
|
|
132
|
+
* unacknowledged intent frames (only intents are writable). The predictor's rebase walks its
|
|
133
|
+
* resim window tick by tick and applies the newest patch stamped at or before each tick
|
|
134
|
+
* (D22 part 2), starting from `baselineIntent` for the ticks before the first of them.
|
|
135
|
+
*/
|
|
136
|
+
pendingWritePatches(collection: string, id: string): readonly {
|
|
137
|
+
tick: number;
|
|
138
|
+
patch: AnyRecord$3;
|
|
139
|
+
}[];
|
|
140
|
+
/**
|
|
141
|
+
* The intent in force for one instance at the start of the resim window: the newest fields
|
|
142
|
+
* that have left the pending buffer (judged into a correction's values, or evicted past the
|
|
143
|
+
* resim depth). `undefined` until the instance's first write leaves the buffer.
|
|
144
|
+
*/
|
|
145
|
+
baselineIntent(collection: string, id: string): AnyRecord$3 | undefined;
|
|
146
|
+
/** Folds a write that left the pending buffer into the per-instance baseline, newest wins. */
|
|
147
|
+
private noteBaseline;
|
|
148
|
+
/** Cheap check for the flush loop: has anything been written locally since the last flush? */
|
|
149
|
+
get hasLocalWrites(): boolean;
|
|
150
|
+
/**
|
|
151
|
+
* The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
|
|
152
|
+
* dirty set either way (a write to an instance that has since been removed or handed away is
|
|
153
|
+
* dropped, not retried forever).
|
|
154
|
+
*/
|
|
155
|
+
takeWrite(tick: number): Uint8Array | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
|
|
158
|
+
* `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
|
|
159
|
+
* Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
|
|
160
|
+
*/
|
|
161
|
+
private retainPendingWrite;
|
|
162
|
+
/** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
|
|
163
|
+
private replayWrite;
|
|
164
|
+
/**
|
|
165
|
+
* Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
|
|
166
|
+
* worker restart) does not silently drop writes that were in flight.
|
|
167
|
+
*/
|
|
168
|
+
remarkOwned(): void;
|
|
169
|
+
/** Only update ops, only instances that still exist and are still mine, never the owner bit. */
|
|
170
|
+
private ownedDirty;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Client-side physics prediction (D22 part 2, D21): a local Rapier world that simulates this
|
|
175
|
+
* client's own bodies — and any `predicted: true` collection's bodies — ahead of the server.
|
|
176
|
+
*
|
|
177
|
+
* ## The contract
|
|
178
|
+
*
|
|
179
|
+
* The world is built from the **shared world-builder** the game passes to
|
|
180
|
+
* `joinRoom({ physics })`: the same `setup` (static geometry), `bodies` (shape factories) and
|
|
181
|
+
* `intents` (intent → force, per step) functions the room's config uses, imported by both sides
|
|
182
|
+
* from one module (`irtio/world.ts` by convention — a convention, not a mechanism). The builder
|
|
183
|
+
* must be pure over synced inputs: no `Math.random()`, no clock, seed/level params read from
|
|
184
|
+
* synced state — or the two worlds are not the same world.
|
|
185
|
+
*
|
|
186
|
+
* ## The loop
|
|
187
|
+
*
|
|
188
|
+
* The local world **free-runs** on a fixed-timestep accumulator driven by render reads: each
|
|
189
|
+
* step applies the owner's current intent values through the `intents` hook, then steps. When
|
|
190
|
+
* authoritative body state arrives (a `CORRECT` for an owned body, a `DELTA` for a non-owned
|
|
191
|
+
* predicted one), every predicted body is **rebased** to the server's values and the world
|
|
192
|
+
* re-steps the client's lead — one step per tick of estimated one-way latency, replaying the
|
|
193
|
+
* buffered unjudged intent frames oldest-first (the write buffer holds exactly the server's
|
|
194
|
+
* input frames — only intents are writable on a physics entity). The lead is bounded by the
|
|
195
|
+
* shared resimulation depth (20, D19): beyond it the body snaps to authority and the snap is
|
|
196
|
+
* counted.
|
|
197
|
+
*
|
|
198
|
+
* ## What is deliberately absent
|
|
199
|
+
*
|
|
200
|
+
* Non-predicted dynamic bodies do not exist in the local world, and neither do `predicted: true`
|
|
201
|
+
* instances that fall over `maxPredictedBodies`. A predicted body that collides with either on
|
|
202
|
+
* the server mispredicts here — it had nothing to collide with, so it passes through and is
|
|
203
|
+
* snapped back by the next correction. That is the documented constraint: predict both
|
|
204
|
+
* (`predicted: true`, within the cap) or interpolate both.
|
|
205
|
+
*
|
|
206
|
+
* The engine itself is loaded lazily via dynamic `import()`, so a game with no physics option —
|
|
207
|
+
* or a bundler that code-splits — pays nothing for it.
|
|
208
|
+
*/
|
|
209
|
+
|
|
210
|
+
type AnyRecord$2 = Record<string, unknown>;
|
|
211
|
+
type ClientRapierModule = typeof RAPIER;
|
|
212
|
+
type ClientRapierWorld = RAPIER.World;
|
|
213
|
+
type ClientRapierBody = RAPIER.RigidBody;
|
|
214
|
+
interface ClientVector3 {
|
|
215
|
+
readonly x: number;
|
|
216
|
+
readonly y: number;
|
|
217
|
+
readonly z: number;
|
|
218
|
+
}
|
|
219
|
+
/** What a client-side body factory returns — the same shape the room config's factories use. */
|
|
220
|
+
interface ClientBodySpec {
|
|
221
|
+
readonly body: RAPIER.RigidBodyDesc;
|
|
222
|
+
readonly colliders?: readonly RAPIER.ColliderDesc[];
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Method-syntax members check bivariantly, so a builder's factory or intent hook written against
|
|
226
|
+
* its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
|
|
227
|
+
* the schema's records for that collection.
|
|
228
|
+
*/
|
|
229
|
+
type ClientBodyFactory = {
|
|
230
|
+
factory(rapier: ClientRapierModule, instance: AnyRecord$2, id: string): ClientBodySpec;
|
|
231
|
+
}['factory'];
|
|
232
|
+
type ClientIntentHook = {
|
|
233
|
+
hook(body: ClientRapierBody, instance: AnyRecord$2, rapier: ClientRapierModule, world: ClientRapierWorld): void;
|
|
234
|
+
}['hook'];
|
|
235
|
+
/**
|
|
236
|
+
* `joinRoom({ physics })` — the client half of the shared world-builder contract. Every function
|
|
237
|
+
* here should be the very export the room config imports, so "same code both sides" stays
|
|
238
|
+
* literally true.
|
|
239
|
+
*/
|
|
240
|
+
interface ClientPhysicsOptions {
|
|
241
|
+
/** Must equal the room config's gravity (put it in the shared module). */
|
|
242
|
+
readonly gravity: ClientVector3;
|
|
243
|
+
/** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
|
|
244
|
+
readonly timestep?: number;
|
|
245
|
+
/** The shared static-geometry builder (the room's `physics.setup`). */
|
|
246
|
+
readonly setup?: (world: ClientRapierWorld, rapier: ClientRapierModule) => void;
|
|
247
|
+
/** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
|
|
248
|
+
readonly bodies?: Readonly<Record<string, ClientBodyFactory>>;
|
|
249
|
+
/**
|
|
250
|
+
* Intent → force, applied before every predicted step for bodies this client owns — the same
|
|
251
|
+
* function the room's `tick()` calls per instance, shared so both simulations agree.
|
|
252
|
+
*/
|
|
253
|
+
readonly intents?: Readonly<Record<string, ClientIntentHook>>;
|
|
254
|
+
/**
|
|
255
|
+
* D21 cap: how many **non-owned** predicted bodies this client simulates. Over-cap instances
|
|
256
|
+
* are **absent from the local world** — they render by interpolation, but predicted bodies
|
|
257
|
+
* pass straight through them, so anything a predicted body stands on or is blocked by has to
|
|
258
|
+
* be under the cap (warned, counted as `stats.overCap`). Default 64.
|
|
259
|
+
*/
|
|
260
|
+
readonly maxPredictedBodies?: number;
|
|
261
|
+
/**
|
|
262
|
+
* A body-field correction whose every value is within this tolerance of the local prediction
|
|
263
|
+
* is *suppressed*: authority still applies, but it is not a misprediction — steady state stays
|
|
264
|
+
* quiet. Positions compare against `epsilon` world units directly; velocity channels compare
|
|
265
|
+
* against `epsilon / timestep` (a velocity disagreement matters by what it moves in one tick —
|
|
266
|
+
* an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
|
|
267
|
+
*/
|
|
268
|
+
readonly epsilon?: number;
|
|
269
|
+
}
|
|
270
|
+
/** Counters for the report and `room.prediction.stats`. */
|
|
271
|
+
interface PredictionStats {
|
|
272
|
+
/** Steps taken free-running ahead of authority. */
|
|
273
|
+
freeSteps: number;
|
|
274
|
+
/** Steps taken re-simulating after a rebase. */
|
|
275
|
+
resimSteps: number;
|
|
276
|
+
/** Rebase passes (one per authoritative arrival batch). */
|
|
277
|
+
rebases: number;
|
|
278
|
+
/** Rebases whose lead outran the resim depth: the body snapped to authority. */
|
|
279
|
+
snaps: number;
|
|
280
|
+
/** Corrections whose values matched the local prediction within epsilon. */
|
|
281
|
+
suppressed: number;
|
|
282
|
+
/**
|
|
283
|
+
* Non-owned predicted instances currently over the cap. They render by interpolation but have
|
|
284
|
+
* no body in the local world, so predicted bodies pass through them: a non-zero value on a
|
|
285
|
+
* collection anything stands on is a gameplay bug, not a fidelity tradeoff.
|
|
286
|
+
*/
|
|
287
|
+
overCap: number;
|
|
288
|
+
/** Microseconds spent in the last rebase's re-steps. */
|
|
289
|
+
lastResimMicros: number;
|
|
290
|
+
}
|
|
291
|
+
declare class PhysicsPredictor {
|
|
292
|
+
private readonly store;
|
|
293
|
+
private readonly options;
|
|
294
|
+
private readonly meOf;
|
|
295
|
+
private readonly rttOf;
|
|
296
|
+
private readonly tickIntervalOf;
|
|
297
|
+
private readonly log;
|
|
298
|
+
readonly stats: PredictionStats;
|
|
299
|
+
private rapier;
|
|
300
|
+
private world;
|
|
301
|
+
private readonly bodies;
|
|
302
|
+
/** Physics-backed entity collections, in schema order. */
|
|
303
|
+
private readonly collections;
|
|
304
|
+
private readonly warned;
|
|
305
|
+
/** Highest over-cap count warned about per collection, so growth re-warns and noise does not. */
|
|
306
|
+
private readonly overCapHigh;
|
|
307
|
+
/** Authority arrived since the last frame: rebase before free-running. */
|
|
308
|
+
private authorityDirty;
|
|
309
|
+
private accumulatorMs;
|
|
310
|
+
private lastNow;
|
|
311
|
+
private lastFrameNow;
|
|
312
|
+
private failed;
|
|
313
|
+
/**
|
|
314
|
+
* Per owned body: the server tick its authoritative record is *from* (the tick of the last
|
|
315
|
+
* `CORRECT` that touched it), and the per-tick prediction history — after every local step,
|
|
316
|
+
* the body's channels are recorded under the tick that step predicted. A correction for tick T
|
|
317
|
+
* is then judged against the client's prediction **for tick T**, not against the current head
|
|
318
|
+
* of the simulation (which legitimately leads authority by the whole latency — comparing
|
|
319
|
+
* against it would report the lead as misprediction).
|
|
320
|
+
*/
|
|
321
|
+
private readonly authorityTicks;
|
|
322
|
+
private readonly predictedTicks;
|
|
323
|
+
private readonly history;
|
|
324
|
+
/** History kept per body — comfortably past the resim depth. */
|
|
325
|
+
private static readonly HISTORY_TICKS;
|
|
326
|
+
constructor(ext: AnySchema, store: ClientStore, options: ClientPhysicsOptions, meOf: () => string, rttOf: () => number, tickIntervalOf: () => number, log?: (message: string) => void);
|
|
327
|
+
get epsilon(): number;
|
|
328
|
+
/** `true` once the engine is loaded and the local world exists. */
|
|
329
|
+
get ready(): boolean;
|
|
330
|
+
/**
|
|
331
|
+
* Kicks off the async engine load. Idempotent. Until it resolves, every read falls back to
|
|
332
|
+
* interpolation/authority — joining is never blocked on 2.9 MB of WASM.
|
|
333
|
+
*/
|
|
334
|
+
start(): Promise<void>;
|
|
335
|
+
free(): void;
|
|
336
|
+
/** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
|
|
337
|
+
reset(): void;
|
|
338
|
+
/** Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one). */
|
|
339
|
+
noteAuthority(): void;
|
|
340
|
+
/** The session tells us which server tick one body's authoritative record is from. */
|
|
341
|
+
noteAuthorityTick(collection: string, id: string, tick: number): void;
|
|
342
|
+
/** Does the local world currently simulate `collection[id]`? */
|
|
343
|
+
has(collection: string, id: string): boolean;
|
|
344
|
+
/**
|
|
345
|
+
* Is a correction's every value within the suppression tolerance of the prediction it judges?
|
|
346
|
+
* Position (and rotation) channels compare against `epsilon` directly; velocity and angular
|
|
347
|
+
* channels against `epsilon / timestep`, because a velocity disagreement matters by what it
|
|
348
|
+
* moves in one tick.
|
|
349
|
+
*/
|
|
350
|
+
withinEpsilon(desc: CollectionDesc, fields: readonly string[], patch: AnyRecord$2, predicted: AnyRecord$2): boolean;
|
|
351
|
+
/** Is `collection` one this client would predict at all (owned always; non-owned per D21)? */
|
|
352
|
+
predictsCollection(name: string): boolean;
|
|
353
|
+
/**
|
|
354
|
+
* Advances the local world to `now`. Driven by render reads (one pass per timestamp), so a
|
|
355
|
+
* draw loop — or a bot's render sampling — is the clock; there is no timer.
|
|
356
|
+
*/
|
|
357
|
+
frame(now: number): void;
|
|
358
|
+
/**
|
|
359
|
+
* The predicted record for `collection[id]`: the authoritative record (which already carries
|
|
360
|
+
* local intent writes — `track()` writes through to plain state) with the body-mapped channels
|
|
361
|
+
* replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
|
|
362
|
+
* reads is what the wire would carry.
|
|
363
|
+
*/
|
|
364
|
+
read(desc: CollectionDesc, id: string, base: AnyRecord$2): AnyRecord$2;
|
|
365
|
+
/**
|
|
366
|
+
* The local world's current values for `fields` of one body — what a correction is judged
|
|
367
|
+
* against (`previous` on the `correct` event, and the epsilon-suppression comparison).
|
|
368
|
+
*/
|
|
369
|
+
predictedValues(desc: CollectionDesc, id: string, fields: readonly string[], atTick?: number): AnyRecord$2 | undefined;
|
|
370
|
+
/**
|
|
371
|
+
* Mirrors the local world's bodies onto the instances this client predicts: every physics
|
|
372
|
+
* instance it owns, plus non-owned instances of `predicted: true` collections up to the cap
|
|
373
|
+
* (collection order, then insertion order — the same stated iteration guarantee the server
|
|
374
|
+
* follows, so which bodies fall over the cap is deterministic).
|
|
375
|
+
*
|
|
376
|
+
* Everything else — non-`predicted` collections, and `predicted` instances over the cap — gets
|
|
377
|
+
* no body and no collider here. Those instances still render (the interpolation path reads
|
|
378
|
+
* authoritative state directly), but nothing in the local world can touch them.
|
|
379
|
+
*/
|
|
380
|
+
private reconcileBodies;
|
|
381
|
+
private createBody;
|
|
382
|
+
/**
|
|
383
|
+
* Over-cap is not a one-time tuning notice: it means those instances are missing from the local
|
|
384
|
+
* world right now, so a predicted body walks through them. Warn on every new high-water mark
|
|
385
|
+
* per collection — a game that grows past the cap mid-session hears about it, and a count that
|
|
386
|
+
* oscillates around one level does not turn the console into a log.
|
|
387
|
+
*/
|
|
388
|
+
private warnOverCap;
|
|
389
|
+
private warnOnce;
|
|
390
|
+
/**
|
|
391
|
+
* The client's lead over authority, in ticks: one-way latency plus one tick of margin.
|
|
392
|
+
* Measured (dive e2e, 2026-08-27): a full-round-trip lead was tried for the tick-aligned
|
|
393
|
+
* replay and made release transitions *worse* (-1.4 to -1.8 u worst backwards step vs -0.6 at
|
|
394
|
+
* half), because the extra lead grows the standing overshoot a rest-settling body has to give
|
|
395
|
+
* back. Half the trip plus margin is where the stamp clock and the correction stream meet.
|
|
396
|
+
*/
|
|
397
|
+
private leadTicks;
|
|
398
|
+
private timestepMs;
|
|
399
|
+
/**
|
|
400
|
+
* Rebase + re-step: snap every predicted body to the authoritative record (server values —
|
|
401
|
+
* `DELTA` for non-owned bodies, `CORRECT` for owned ones; body fields are never client-
|
|
402
|
+
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
403
|
+
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
404
|
+
* the newest buffered unjudged write stamped at or before it, or the baseline (the newest
|
|
405
|
+
* judged write) before the first of them. Bounded by the shared resim depth: an outrun lead
|
|
406
|
+
* snaps to authority and counts (`stats.snaps`).
|
|
407
|
+
*/
|
|
408
|
+
private rebase;
|
|
409
|
+
/**
|
|
410
|
+
* The tick a `WRITE` flushed right now should be stamped with: one past the newest predicted
|
|
411
|
+
* head across owned bodies — the first tick the new intent can affect. That puts the stamp on
|
|
412
|
+
* the same clock as `authorityTicks` and the resim window (the server's tick stream), which is
|
|
413
|
+
* what lets the rebase walk pending writes by tick instead of by array index. `undefined`
|
|
414
|
+
* until the engine runs or while nothing is owned; the session falls back to its counter.
|
|
415
|
+
*/
|
|
416
|
+
stampTick(): number | undefined;
|
|
417
|
+
/** After every step: advance each owned body's prediction clock and remember its channels. */
|
|
418
|
+
private recordStep;
|
|
419
|
+
private freeRun;
|
|
420
|
+
/**
|
|
421
|
+
* Applies intents for every owned predicted body before one step: the intent in force at the
|
|
422
|
+
* tick this step predicts (during a rebase — see `rebase`'s replay walk), else the instance's
|
|
423
|
+
* current values (free-run: plain state carries the newest local intent writes, which is
|
|
424
|
+
* correct there because free-run steps are the ticks *after* every buffered write).
|
|
425
|
+
*/
|
|
426
|
+
private applyIntents;
|
|
427
|
+
/** Server record → body channels (the same mapping the runtime's sync uses, inverted). */
|
|
428
|
+
private applyRecord;
|
|
429
|
+
private isF32;
|
|
430
|
+
}
|
|
431
|
+
|
|
13
432
|
/**
|
|
14
433
|
* A client-side entity collection: the read API of `@irtio/schema`'s `ReadonlyCollection`, plus
|
|
15
434
|
* index sugar so `state.players[room.me]` reads the same as `state.players.get(room.me)`.
|
|
@@ -56,6 +475,11 @@ interface Correction {
|
|
|
56
475
|
readonly fields: readonly string[];
|
|
57
476
|
/** The server's values for those fields (already applied to `room.state`). */
|
|
58
477
|
readonly patch: Readonly<Record<string, unknown>>;
|
|
478
|
+
/**
|
|
479
|
+
* The local (predicted) values of `fields` right before the snap. Misprediction magnitude is
|
|
480
|
+
* the numeric distance between `previous` and `patch`, per field.
|
|
481
|
+
*/
|
|
482
|
+
readonly previous: Readonly<Record<string, unknown>>;
|
|
59
483
|
readonly tick: number;
|
|
60
484
|
/**
|
|
61
485
|
* The client write tick the server judged (D19, week 8): every local write through it is
|
|
@@ -70,6 +494,19 @@ interface Correction {
|
|
|
70
494
|
* writes) — everything it named snapped to the server's values, nothing was replayed.
|
|
71
495
|
*/
|
|
72
496
|
readonly snapped: boolean;
|
|
497
|
+
/**
|
|
498
|
+
* D22: every corrected field is a simulation-owned body field this client does **not**
|
|
499
|
+
* predict — not a disagreement, just server-authoritative body state reaching its owner (once
|
|
500
|
+
* per tick, by design). When the client predicts the body, this is `false` and the correction
|
|
501
|
+
* is a real misprediction: `previous` holds the local world's predicted values.
|
|
502
|
+
*/
|
|
503
|
+
readonly simulation: boolean;
|
|
504
|
+
/**
|
|
505
|
+
* D22 part 2: a predicted body's correction whose every value matched the local prediction
|
|
506
|
+
* within the epsilon — authority applied, but there was nothing to disagree about. Suppressed
|
|
507
|
+
* corrections keep a healthy predicted body's steady state quiet.
|
|
508
|
+
*/
|
|
509
|
+
readonly suppressed: boolean;
|
|
73
510
|
}
|
|
74
511
|
interface RoomEvents {
|
|
75
512
|
status: Status;
|
|
@@ -134,9 +571,30 @@ interface JoinOptions<S, Role extends string = string> {
|
|
|
134
571
|
* (which is what `irtio dev` uses when there is no `irtio.json`).
|
|
135
572
|
*/
|
|
136
573
|
readonly key?: string;
|
|
574
|
+
/**
|
|
575
|
+
* Week 13 (D27): a JWT asserting who this player is, minted by your server with the project's
|
|
576
|
+
* signing secret. `ctx.playerId` becomes the token's `sub`, and a `role` claim overrides
|
|
577
|
+
* `role` here. A function is called before every HELLO — reconnects included — so it can hand
|
|
578
|
+
* back a fresh token when the old one nears expiry. Omitted ⇒ the key-only join, unchanged.
|
|
579
|
+
*/
|
|
580
|
+
readonly token?: string | (() => string | Promise<string>);
|
|
137
581
|
readonly onStatus?: (status: Status) => void;
|
|
138
582
|
/** Hard cap on the owned-write flush window, in ms. Default 50. */
|
|
139
583
|
readonly writeIntervalMs?: number;
|
|
584
|
+
/**
|
|
585
|
+
* How far behind arrival `room.render` draws non-owned entities, in ms (D20). Default:
|
|
586
|
+
* `2 × tickIntervalMs` (the room's tick interval, learned from `WELCOME`), floored at 50 ms.
|
|
587
|
+
*/
|
|
588
|
+
readonly interpDelayMs?: number;
|
|
589
|
+
/**
|
|
590
|
+
* D22 part 2: the client half of the shared world-builder contract — the same `setup`,
|
|
591
|
+
* `bodies` and `intents` functions the room's `physics` config imports (by convention from
|
|
592
|
+
* `irtio/world.ts`), plus `gravity`. With it, this client simulates its own bodies (and any
|
|
593
|
+
* `predicted: true` collection's, capped) ahead in a local world; without it, body-backed
|
|
594
|
+
* entities interpolate like everything else. The engine loads lazily — a bundler that
|
|
595
|
+
* code-splits keeps Rapier out of the critical path entirely.
|
|
596
|
+
*/
|
|
597
|
+
readonly physics?: ClientPhysicsOptions;
|
|
140
598
|
/** @internal */
|
|
141
599
|
readonly transport?: Transport;
|
|
142
600
|
/** @internal */
|
|
@@ -163,6 +621,27 @@ interface JoinRelayOptions {
|
|
|
163
621
|
type MessageTarget = 'all' | string | {
|
|
164
622
|
readonly role: string;
|
|
165
623
|
};
|
|
624
|
+
/**
|
|
625
|
+
* Default D21 cap on non-owned predicted bodies per client.
|
|
626
|
+
*
|
|
627
|
+
* Sized against measurement, not caution: 41 dynamic bodies stacked in contact step in 0.005 to
|
|
628
|
+
* 0.007 ms (`games/dive/spike`, 2026-08-26), and a rebase re-steps the local world once per lead
|
|
629
|
+
* tick per authoritative tick — about 0.5 ms per second of wall clock at 30Hz with a 3-tick lead.
|
|
630
|
+
* Body count is not what makes prediction expensive, and the cost of a cap set too low is not
|
|
631
|
+
* saved CPU: over-cap instances are **absent from the local world** (see `reconcileBodies`), so
|
|
632
|
+
* a predicted body passes through them.
|
|
633
|
+
*/
|
|
634
|
+
declare const MAX_PREDICTED_BODIES = 64;
|
|
635
|
+
/** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
|
|
636
|
+
declare const PREDICTION_EPSILON = 0.05;
|
|
637
|
+
/** `room.prediction` (D22 part 2). */
|
|
638
|
+
interface PredictionStatus {
|
|
639
|
+
/** The engine is loaded and the local world exists. */
|
|
640
|
+
readonly active: boolean;
|
|
641
|
+
/** Is `collection[id]` currently simulated in the local world? */
|
|
642
|
+
predicts(collection: string, id: string): boolean;
|
|
643
|
+
readonly stats: PredictionStats;
|
|
644
|
+
}
|
|
166
645
|
/**
|
|
167
646
|
* `room.call.<rpc>(params)` — every server RPC the builder declared, plus the one built-in.
|
|
168
647
|
* `requestOwnership` takes the entity/id pair positionally and unwraps `{ granted }`.
|
|
@@ -184,8 +663,23 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
|
|
|
184
663
|
/** Round-trip time in ms from the last `PING`/`PONG`, or 0 before the first one. */
|
|
185
664
|
readonly rtt: number;
|
|
186
665
|
readonly state: ClientState<S, Role>;
|
|
666
|
+
/**
|
|
667
|
+
* The render read path (D20): same shapes as `room.state`, but non-owned entities are
|
|
668
|
+
* interpolated at `now − interpDelayMs` (numeric fields lerp, everything else steps) while
|
|
669
|
+
* owned entities return the predicted local values. A draw loop opts in by one substitution —
|
|
670
|
+
* `room.state.players` → `room.render.players`. Tests and game logic keep reading
|
|
671
|
+
* `room.state`, which stays authoritative.
|
|
672
|
+
*/
|
|
673
|
+
readonly render: ClientState<S, Role>;
|
|
187
674
|
/** Built-in presence, ordered by join. */
|
|
188
675
|
readonly clients: readonly PresenceRecord[];
|
|
676
|
+
/**
|
|
677
|
+
* Physics-prediction status, present when the join passed `physics` and the schema has
|
|
678
|
+
* body-backed collections (D22 part 2). `active` flips true once the engine loads; `predicts`
|
|
679
|
+
* says whether one instance currently reads from the local world; `stats` carries the
|
|
680
|
+
* counters the bot runtime and the demos report.
|
|
681
|
+
*/
|
|
682
|
+
readonly prediction?: PredictionStatus;
|
|
189
683
|
readonly call: RoomCallProxy<S>;
|
|
190
684
|
/** Convenience alias for `room.call.requestOwnership`. */
|
|
191
685
|
requestOwnership(entity: string, id: string): Promise<boolean>;
|
|
@@ -260,141 +754,58 @@ declare function defaultScheduler(): Scheduler;
|
|
|
260
754
|
declare const webSocketTransport: Transport;
|
|
261
755
|
|
|
262
756
|
/**
|
|
263
|
-
* The
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
757
|
+
* The interpolated render read path (D20, week 8): `room.render.<collection>` mirrors
|
|
758
|
+
* `room.state`'s shapes, but non-owned entities are rendered at `now − interpDelayMs`, lerping
|
|
759
|
+
* numeric fields between the two bracketing DELTAs and stepping everything else (strings, bools,
|
|
760
|
+
* enums, refs, lists). Owned entities return the predicted (local) values — the same object
|
|
761
|
+
* `room.state` hands out — so one read path serves the whole draw loop.
|
|
267
762
|
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* hint.
|
|
273
|
-
*
|
|
274
|
-
* Which one you get is decided per `get()`, so an ownership change needs no bookkeeping — but the
|
|
275
|
-
* object identity is not stable across it, which is why `state.x.get(id)` must be re-read after a
|
|
276
|
-
* grab (documented in the README).
|
|
277
|
-
*
|
|
278
|
-
* The server-wins rule lives here too: a `DELTA`'s update ops are dropped field-wise for
|
|
279
|
-
* instances this client owns (adds, removes and owner changes still apply), while a `CORRECT`
|
|
280
|
-
* applies in full and clears the matching local dirty marks.
|
|
763
|
+
* `room.state` stays authoritative and untouched: tests, bots and existing code see exactly what
|
|
764
|
+
* they saw before. Entities appear and disappear at the buffered time; past the newest delta the
|
|
765
|
+
* value holds (no extrapolation) and the starvation is counted. A collection declared
|
|
766
|
+
* `interpolate: false` (and every singleton) reads straight through to the authoritative view.
|
|
281
767
|
*/
|
|
282
768
|
|
|
283
769
|
type AnyRecord$1 = Record<string, unknown>;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
* corrects and counts it (`snapped`); buffer memory is bounded by the same window.
|
|
288
|
-
*/
|
|
289
|
-
declare const RESIM_DEPTH = 20;
|
|
290
|
-
/** One correction op, as the `'correct'` event sees it. */
|
|
291
|
-
interface CorrectionOp {
|
|
292
|
-
readonly collection: string;
|
|
293
|
-
readonly id: string;
|
|
294
|
-
readonly fields: readonly string[];
|
|
295
|
-
readonly patch: AnyRecord$1;
|
|
296
|
-
readonly tick: number;
|
|
297
|
-
/**
|
|
298
|
-
* The client write tick the server judged (D19), absent against a pre-week-8 server. Local
|
|
299
|
-
* writes newer than it were re-applied on top of the correction.
|
|
300
|
-
*/
|
|
301
|
-
readonly clientTick?: number;
|
|
302
|
-
/** How many pending local writes were re-applied over this correction's values. */
|
|
303
|
-
readonly replayed: number;
|
|
304
|
-
/** `true` when the correction outran the resim window and everything it named snapped. */
|
|
305
|
-
readonly snapped: boolean;
|
|
306
|
-
}
|
|
307
|
-
declare class ClientStore {
|
|
308
|
-
readonly ext: AnySchema;
|
|
309
|
-
/** Reads the current client id — it is not known until the first `WELCOME`. */
|
|
770
|
+
declare class RenderStore {
|
|
771
|
+
private readonly ext;
|
|
772
|
+
private readonly store;
|
|
310
773
|
private readonly meOf;
|
|
311
|
-
|
|
312
|
-
private
|
|
774
|
+
private readonly now;
|
|
775
|
+
private readonly delayMs;
|
|
776
|
+
/** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
|
|
777
|
+
private readonly buffers;
|
|
313
778
|
private readonly descs;
|
|
314
|
-
|
|
315
|
-
/** The object handed out as `room.state`; identity survives a resync. */
|
|
779
|
+
/** The object handed out as `room.render`; identity survives a resync. */
|
|
316
780
|
readonly view: AnyRecord$1;
|
|
317
|
-
/**
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
private
|
|
321
|
-
constructor(ext: AnySchema,
|
|
322
|
-
/** Reads the current client id — it is not known until the first `WELCOME`. */
|
|
323
|
-
meOf: () => string);
|
|
324
|
-
loadSnapshot(bytes: Uint8Array): void;
|
|
325
|
-
/**
|
|
326
|
-
* Captures the field values of every pending owned write, keyed by collection then id then
|
|
327
|
-
* field name — call this **before** `loadSnapshot` on a resync, or the edit is gone once the
|
|
328
|
-
* old `plain` it lives in is replaced. Paired with `applyPendingWrites`.
|
|
329
|
-
*/
|
|
330
|
-
capturePendingWrites(): Map<string, Map<string, AnyRecord$1>>;
|
|
781
|
+
/** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
|
|
782
|
+
starved: number;
|
|
783
|
+
/** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
|
|
784
|
+
private predictor;
|
|
785
|
+
constructor(ext: AnySchema, store: ClientStore, meOf: () => string, now: () => number, delayMs: () => number);
|
|
331
786
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* whatever the resync snapshot happened to carry for that field.
|
|
787
|
+
* Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
|
|
788
|
+
* the world is visible immediately (the join snapshot is the render baseline, not a change to
|
|
789
|
+
* ease towards).
|
|
336
790
|
*/
|
|
337
|
-
|
|
791
|
+
seedSnapshot(): void;
|
|
792
|
+
/** Buffers every entity a `DELTA` touched, stamped with its arrival time. Call after apply. */
|
|
793
|
+
recordDelta(delta: Delta): void;
|
|
794
|
+
private bufferFor;
|
|
795
|
+
private renderTime;
|
|
796
|
+
attachPredictor(predictor: PhysicsPredictor): void;
|
|
797
|
+
/** The interpolated (or predicted, or authoritative) value of `collection[id]` right now. */
|
|
798
|
+
get(desc: CollectionDesc, id: string): unknown;
|
|
799
|
+
/** Is `collection[id]` visible at the render clock? */
|
|
800
|
+
has(desc: CollectionDesc, id: string): boolean;
|
|
801
|
+
/** The authoritative owner — ownership is fact, not a rendered value. */
|
|
802
|
+
ownerOf(desc: CollectionDesc, id: string): string | undefined;
|
|
803
|
+
/** Ids visible at the render clock: owned (predicted) plus buffered-and-appeared. */
|
|
804
|
+
ids(desc: CollectionDesc): IterableIterator<string>;
|
|
805
|
+
/** Drops frames the render clock has passed, keeping the newest one at-or-before `renderT`. */
|
|
806
|
+
private prune;
|
|
807
|
+
private gc;
|
|
338
808
|
private buildView;
|
|
339
|
-
/** The writable tracked proxy when this client owns `id`, otherwise a frozen one. */
|
|
340
|
-
instance(desc: CollectionDesc, id: string): unknown;
|
|
341
|
-
private freeze;
|
|
342
|
-
private entityHint;
|
|
343
|
-
private singletonHint;
|
|
344
|
-
/**
|
|
345
|
-
* Applies a server `DELTA`. Update ops for instances this client owns are dropped field-wise
|
|
346
|
-
* (server wins only through `CORRECT`); adds, removes and owner changes always apply.
|
|
347
|
-
*/
|
|
348
|
-
applyServerDelta(delta: Delta): void;
|
|
349
|
-
/**
|
|
350
|
-
* The flushed-write half of the §7.2 in-flight own-write fix: an `add` op for an instance this
|
|
351
|
-
* client owns replaces the whole record, so any write already *flushed* (dirty set consumed —
|
|
352
|
-
* `preserveLocalWrites` cannot see it) but not yet judged would be reverted by the echo.
|
|
353
|
-
* Re-apply the retained pending writes in flush order; the server's eventual `CORRECT` (if the
|
|
354
|
-
* write is clamped) still wins through the snap+replay path.
|
|
355
|
-
*/
|
|
356
|
-
private replayOverAddEchoes;
|
|
357
|
-
private withoutOwnedUpdates;
|
|
358
|
-
/**
|
|
359
|
-
* An `add` for an instance this client will own, with its unflushed local field values folded
|
|
360
|
-
* back in. Returns `op` unchanged when there is nothing to preserve.
|
|
361
|
-
*/
|
|
362
|
-
private preserveLocalWrites;
|
|
363
|
-
/**
|
|
364
|
-
* Applies a `CORRECT`: snap the named fields to the server's values, then re-apply every local
|
|
365
|
-
* write newer than the judged `clientTick` in order (D19 snap + replay) — flushed writes from
|
|
366
|
-
* the retained buffer first, the still-unflushed in-window edit last (it is the newest, and its
|
|
367
|
-
* dirty mark survives so the next flush re-sends the *local* value, not the server's).
|
|
368
|
-
*
|
|
369
|
-
* Without a `clientTick` (a pre-week-8 server) — or when the correction outran the
|
|
370
|
-
* `RESIM_DEPTH` window (`snapped`) — the pre-D19 semantics apply: the correction wins in full
|
|
371
|
-
* and the local dirty marks it supersedes are cleared.
|
|
372
|
-
*/
|
|
373
|
-
applyCorrection(delta: Delta, clientTick?: number): CorrectionOp[];
|
|
374
|
-
private desc;
|
|
375
|
-
/** Cheap check for the flush loop: has anything been written locally since the last flush? */
|
|
376
|
-
get hasLocalWrites(): boolean;
|
|
377
|
-
/**
|
|
378
|
-
* The `WRITE` payload for everything dirty, or `undefined` when nothing qualifies. Consumes the
|
|
379
|
-
* dirty set either way (a write to an instance that has since been removed or handed away is
|
|
380
|
-
* dropped, not retried forever).
|
|
381
|
-
*/
|
|
382
|
-
takeWrite(tick: number): Uint8Array | undefined;
|
|
383
|
-
/**
|
|
384
|
-
* Captures the field values a flushed `WRITE` carried, keyed by its write tick, so a later
|
|
385
|
-
* `CORRECT` can re-apply exactly the writes the server had not judged yet (D19 snap + replay).
|
|
386
|
-
* Bounded to `RESIM_DEPTH` entries; eviction is remembered so an outrun correction snaps.
|
|
387
|
-
*/
|
|
388
|
-
private retainPendingWrite;
|
|
389
|
-
/** Re-applies one retained write's fields for `collection[id]` onto the plain state. */
|
|
390
|
-
private replayWrite;
|
|
391
|
-
/**
|
|
392
|
-
* Re-marks every field of every instance this client owns, so a resync `WELCOME` (wake or
|
|
393
|
-
* worker restart) does not silently drop writes that were in flight.
|
|
394
|
-
*/
|
|
395
|
-
remarkOwned(): void;
|
|
396
|
-
/** Only update ops, only instances that still exist and are still mine, never the owner bit. */
|
|
397
|
-
private ownedDirty;
|
|
398
809
|
}
|
|
399
810
|
|
|
400
811
|
/**
|
|
@@ -419,10 +830,21 @@ interface SessionOptions {
|
|
|
419
830
|
readonly url: string;
|
|
420
831
|
readonly roomId: string;
|
|
421
832
|
readonly key: string;
|
|
833
|
+
/**
|
|
834
|
+
* Week 13 (D27): a JWT asserting who this player is, minted by YOUR server with the project's
|
|
835
|
+
* signing secret (`irtio keys jwt-secret`). A function is called before every HELLO — every
|
|
836
|
+
* reconnect included — so a fresh token can be supplied when the old one nears its `exp`; a
|
|
837
|
+
* resume that outlives its token would otherwise be refused with `E_TOKEN_EXPIRED`.
|
|
838
|
+
*/
|
|
839
|
+
readonly token?: string | (() => string | Promise<string>) | undefined;
|
|
422
840
|
readonly role?: string | undefined;
|
|
423
841
|
readonly name?: string | undefined;
|
|
424
842
|
readonly rpc?: Readonly<Record<string, ClientImpl>> | undefined;
|
|
425
843
|
readonly writeIntervalMs?: number | undefined;
|
|
844
|
+
/** Render delay for `room.render` (D20). Default `max(50, 2 × tickIntervalMs)`. */
|
|
845
|
+
readonly interpDelayMs?: number | undefined;
|
|
846
|
+
/** The shared world-builder half the client predicts with (D22 part 2). */
|
|
847
|
+
readonly physics?: ClientPhysicsOptions | undefined;
|
|
426
848
|
readonly transport?: Transport | undefined;
|
|
427
849
|
readonly scheduler?: Scheduler | undefined;
|
|
428
850
|
readonly onFrame?: FrameHook | undefined;
|
|
@@ -434,6 +856,14 @@ declare class Session {
|
|
|
434
856
|
private readonly options;
|
|
435
857
|
readonly ext: AnySchema;
|
|
436
858
|
readonly store: ClientStore;
|
|
859
|
+
readonly render: RenderStore;
|
|
860
|
+
/** `true` when the join passed `physics` and the schema has body-backed collections. */
|
|
861
|
+
readonly predictionRequested: boolean;
|
|
862
|
+
/**
|
|
863
|
+
* Present once `./physics.js` has loaded (dynamic import — the predictor code, like the
|
|
864
|
+
* engine, costs a non-physics game zero bytes). Reads fall back to interpolation until then.
|
|
865
|
+
*/
|
|
866
|
+
predictor: PhysicsPredictor | undefined;
|
|
437
867
|
/** The schema the `CALL`/`REPLY` rpc id space indexes into. */
|
|
438
868
|
private readonly rpcSchema;
|
|
439
869
|
private readonly transport;
|
|
@@ -445,9 +875,11 @@ declare class Session {
|
|
|
445
875
|
roomId: string;
|
|
446
876
|
tick: number;
|
|
447
877
|
/**
|
|
448
|
-
* The
|
|
878
|
+
* The stamp of the newest flushed `WRITE`, carried in its delta header (D19). With physics
|
|
879
|
+
* prediction live this is the predictor's head tick + 1 — the server's tick clock, which is
|
|
880
|
+
* what lets the rebase replay pending writes by tick — and otherwise a bare counter.
|
|
449
881
|
* Monotonic for the life of the session, across reconnects — the server's `lastClientTick`
|
|
450
|
-
* for this client survives the grace window too.
|
|
882
|
+
* for this client survives the grace window too, and only ever moves forward.
|
|
451
883
|
*/
|
|
452
884
|
writeTick: number;
|
|
453
885
|
/** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
|
|
@@ -455,6 +887,19 @@ declare class Session {
|
|
|
455
887
|
rtt: number;
|
|
456
888
|
status: Status;
|
|
457
889
|
private socket;
|
|
890
|
+
/**
|
|
891
|
+
* Whether `socket` has actually opened. `TransportSocket` deliberately has no `readyState` —
|
|
892
|
+
* `@irtio/testing` injects an in-process pair — so the session tracks it from `onopen`.
|
|
893
|
+
*
|
|
894
|
+
* Needed because a reconnecting session holds a socket that exists and is *not* sendable:
|
|
895
|
+
* `onSocketClosed` clears `socket` but keeps `joined` true (that is what makes resume work), and
|
|
896
|
+
* `connect()` then assigns a fresh socket that is still CONNECTING. Every send guard here used to
|
|
897
|
+
* test `this.socket` for presence alone, so the ping timer and the write flush would both reach a
|
|
898
|
+
* connecting socket and throw `InvalidStateError: Sent before connected.` out of a timer callback,
|
|
899
|
+
* where nothing catches it. Found on staging under 40+ bots, where joins are slow enough for the
|
|
900
|
+
* window to be wide; it never opens against a local dev server.
|
|
901
|
+
*/
|
|
902
|
+
private socketOpen;
|
|
458
903
|
private resumeToken;
|
|
459
904
|
private joined;
|
|
460
905
|
private left;
|
|
@@ -470,6 +915,11 @@ declare class Session {
|
|
|
470
915
|
private settleJoin;
|
|
471
916
|
private failJoin;
|
|
472
917
|
constructor(options: SessionOptions);
|
|
918
|
+
/**
|
|
919
|
+
* The `room.render` delay (D20): the explicit option, else twice the room's tick interval
|
|
920
|
+
* (from `WELCOME`), floored at 50 ms — also the fallback when the interval is unknown.
|
|
921
|
+
*/
|
|
922
|
+
get interpDelayMs(): number;
|
|
473
923
|
/** Connects and resolves on the first `WELCOME`; a fatal `ERROR` before it rejects. */
|
|
474
924
|
start(): Promise<Session>;
|
|
475
925
|
private connect;
|
|
@@ -487,6 +937,10 @@ declare class Session {
|
|
|
487
937
|
private onWelcome;
|
|
488
938
|
private onDelta;
|
|
489
939
|
private onCorrect;
|
|
940
|
+
private descsByName;
|
|
941
|
+
private descOfCollection;
|
|
942
|
+
/** Pre-rebase predicted values per corrected instance, keyed `collection id`. */
|
|
943
|
+
private capturePredicted;
|
|
490
944
|
private onError;
|
|
491
945
|
private onPong;
|
|
492
946
|
private onMsg;
|
|
@@ -497,6 +951,7 @@ declare class Session {
|
|
|
497
951
|
* inside a window leaves as a single `WRITE`.
|
|
498
952
|
*/
|
|
499
953
|
private armFlush;
|
|
954
|
+
private sendPing;
|
|
500
955
|
private armPing;
|
|
501
956
|
/** Sends every pending owned write now. */
|
|
502
957
|
flush(): void;
|
|
@@ -553,4 +1008,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
|
|
|
553
1008
|
*/
|
|
554
1009
|
declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
|
|
555
1010
|
|
|
556
|
-
export { CALL_TIMEOUT_MS, type ClientCollection, type ClientState, ClientStore, type Correction, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, type FrameHook, type JoinOptions, type JoinRelayOptions, type MessageTarget, PING_INTERVAL_MS, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, defaultScheduler, joinRelay, joinRoom, linkForUrl, resolveUrl, roomIdFrom, webSocketTransport };
|
|
1011
|
+
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, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, defaultScheduler, joinRelay, joinRoom, linkForUrl, resolveUrl, roomIdFrom, webSocketTransport };
|