@irtio/client 0.2.0 → 0.3.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 → chunk-7UTJ7RSF.js} +7 -1
- package/dist/index.d.ts +250 -14
- package/dist/index.js +22 -7
- package/dist/physics-H2VDQLAU.js +1059 -0
- package/package.json +3 -3
- package/dist/physics-BBFSQEYL.js +0 -583
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
var E_CONNECT_FAILED = "E_CONNECT_FAILED";
|
|
3
3
|
var MAX_PREDICTED_BODIES = 64;
|
|
4
4
|
var PREDICTION_EPSILON = 0.05;
|
|
5
|
+
var SMOOTHING_HALF_LIFE_MS = 70;
|
|
6
|
+
var SMOOTHING_SNAP_UNITS = 4;
|
|
5
7
|
function emptyPredictionStats() {
|
|
6
8
|
return {
|
|
7
9
|
freeSteps: 0,
|
|
@@ -10,7 +12,9 @@ function emptyPredictionStats() {
|
|
|
10
12
|
snaps: 0,
|
|
11
13
|
suppressed: 0,
|
|
12
14
|
overCap: 0,
|
|
13
|
-
lastResimMicros: 0
|
|
15
|
+
lastResimMicros: 0,
|
|
16
|
+
smoothing: 0,
|
|
17
|
+
stampGap: 0
|
|
14
18
|
};
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -556,6 +560,8 @@ export {
|
|
|
556
560
|
E_CONNECT_FAILED,
|
|
557
561
|
MAX_PREDICTED_BODIES,
|
|
558
562
|
PREDICTION_EPSILON,
|
|
563
|
+
SMOOTHING_HALF_LIFE_MS,
|
|
564
|
+
SMOOTHING_SNAP_UNITS,
|
|
559
565
|
emptyPredictionStats,
|
|
560
566
|
RESIM_DEPTH,
|
|
561
567
|
ClientStore
|
package/dist/index.d.ts
CHANGED
|
@@ -195,6 +195,17 @@ declare class ClientStore {
|
|
|
195
195
|
* shared resimulation depth (20, D19): beyond it the body snaps to authority and the snap is
|
|
196
196
|
* counted.
|
|
197
197
|
*
|
|
198
|
+
* ## What the renderer sees
|
|
199
|
+
*
|
|
200
|
+
* Not the head. The head moves once per server tick — and only when an arrival rebases the world
|
|
201
|
+
* onto it — while a display asks two to four times as often, so handing back the head draws a
|
|
202
|
+
* character running at a steady nine units a second as standing still for half the frames and
|
|
203
|
+
* moving at twenty for the rest. `read()` therefore keeps a short ring of recent poses per body
|
|
204
|
+
* and draws between the two either side of a render clock that runs on wall time, steered to sit
|
|
205
|
+
* about a tick behind the head. Correctness is unaffected: `room.state`, the per-tick history a
|
|
206
|
+
* correction is judged against, and `predictedValues` all still see the head. See bug 7 in
|
|
207
|
+
* `docs/bugs.md` for the measurement.
|
|
208
|
+
*
|
|
198
209
|
* ## What is deliberately absent
|
|
199
210
|
*
|
|
200
211
|
* Non-predicted dynamic bodies do not exist in the local world, and neither do `predicted: true`
|
|
@@ -266,6 +277,32 @@ interface ClientPhysicsOptions {
|
|
|
266
277
|
* an input frame landing one tick late on the server is invisible, not a storm). Default 0.05.
|
|
267
278
|
*/
|
|
268
279
|
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
|
+
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
|
+
readonly smoothingSnapUnits?: number;
|
|
269
306
|
}
|
|
270
307
|
/** Counters for the report and `room.prediction.stats`. */
|
|
271
308
|
interface PredictionStats {
|
|
@@ -287,6 +324,21 @@ interface PredictionStats {
|
|
|
287
324
|
overCap: number;
|
|
288
325
|
/** Microseconds spent in the last rebase's re-steps. */
|
|
289
326
|
lastResimMicros: number;
|
|
327
|
+
/**
|
|
328
|
+
* The largest render-error offset currently being eased away, in world units — how far the
|
|
329
|
+
* furthest-off predicted body is being drawn from where the simulation puts it, so that a
|
|
330
|
+
* re-simulation did not arrive as a step (`smoothingHalfLifeMs`). It rises on a correction and
|
|
331
|
+
* falls back towards zero; what is unhealthy is a value that *stays* up, which means the
|
|
332
|
+
* client is being corrected as fast as it can absorb it.
|
|
333
|
+
*/
|
|
334
|
+
smoothing: number;
|
|
335
|
+
/**
|
|
336
|
+
* Ticks between the stamp a `WRITE` carries and the server tick it was applied at, smoothed
|
|
337
|
+
* (bug 1). The client stamps on its predicted clock, which leads authority, so this is how far
|
|
338
|
+
* *early* a naive replay would put every pending intent. 0 until a server that reports the
|
|
339
|
+
* applied tick judges a stamped write.
|
|
340
|
+
*/
|
|
341
|
+
stampGap: number;
|
|
290
342
|
}
|
|
291
343
|
declare class PhysicsPredictor {
|
|
292
344
|
private readonly store;
|
|
@@ -310,6 +362,27 @@ declare class PhysicsPredictor {
|
|
|
310
362
|
private lastNow;
|
|
311
363
|
private lastFrameNow;
|
|
312
364
|
private failed;
|
|
365
|
+
/**
|
|
366
|
+
* The local world's head, on the server's tick timeline: the authoritative tick the last
|
|
367
|
+
* rebase started from plus the lead it re-stepped, then one per free-run step.
|
|
368
|
+
*/
|
|
369
|
+
private headTick;
|
|
370
|
+
/** The newest server tick any authority has been seen for. `headTick`'s base. */
|
|
371
|
+
private authorityTick;
|
|
372
|
+
/** The newest tick a pose has been stored for. `-1` until the first capture. */
|
|
373
|
+
private curTick;
|
|
374
|
+
/**
|
|
375
|
+
* Where the renderer is, as a fractional tick. Advanced by wall time every frame and steered
|
|
376
|
+
* to sit about a tick behind `curTick`; `read()` draws between the stored poses either side
|
|
377
|
+
* of it.
|
|
378
|
+
*/
|
|
379
|
+
private renderTick;
|
|
380
|
+
/** Scratch for one interpolated pose. `read()` is synchronous, so one is enough. */
|
|
381
|
+
private readonly scratch;
|
|
382
|
+
/** Scratch quaternions, so the per-body per-frame offset maths allocates nothing. */
|
|
383
|
+
private readonly qa;
|
|
384
|
+
private readonly qb;
|
|
385
|
+
private readonly qc;
|
|
313
386
|
/**
|
|
314
387
|
* Per owned body: the server tick its authoritative record is *from* (the tick of the last
|
|
315
388
|
* `CORRECT` that touched it), and the per-tick prediction history — after every local step,
|
|
@@ -319,6 +392,11 @@ declare class PhysicsPredictor {
|
|
|
319
392
|
* against it would report the lead as misprediction).
|
|
320
393
|
*/
|
|
321
394
|
private readonly authorityTicks;
|
|
395
|
+
/** Smoothed stamp-minus-applied gap; see `noteWriteApplied`. */
|
|
396
|
+
private stampGap;
|
|
397
|
+
private gapMeasured;
|
|
398
|
+
/** The newest stamp already folded into `stampGap`, so a repeated echo is not re-weighted. */
|
|
399
|
+
private gapSampledThrough;
|
|
322
400
|
private readonly predictedTicks;
|
|
323
401
|
private readonly history;
|
|
324
402
|
/** History kept per body — comfortably past the resim depth. */
|
|
@@ -335,10 +413,32 @@ declare class PhysicsPredictor {
|
|
|
335
413
|
free(): void;
|
|
336
414
|
/** A resync (`WELCOME`) replaced the authority wholesale: rebase everything, drop banked time. */
|
|
337
415
|
reset(): void;
|
|
338
|
-
/**
|
|
339
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Authoritative body state arrived (`DELTA` on a non-owned body, `CORRECT` on an owned one).
|
|
418
|
+
*
|
|
419
|
+
* `tick` is the server tick it carried, and it is the base of the predictor's own head clock —
|
|
420
|
+
* which the renderer's interpolation is paced against. It has to be the *world's* clock rather
|
|
421
|
+
* than any one body's: a client with nothing of its own to own still draws a world full of
|
|
422
|
+
* predicted bodies.
|
|
423
|
+
*/
|
|
424
|
+
noteAuthority(tick?: number): void;
|
|
340
425
|
/** The session tells us which server tick one body's authoritative record is from. */
|
|
341
426
|
noteAuthorityTick(collection: string, id: string, tick: number): void;
|
|
427
|
+
/**
|
|
428
|
+
* A `CORRECT` reported both halves of a judged write: the stamp the client put on it and the
|
|
429
|
+
* server tick it was actually applied at (bug 1). The gap between them is the systematic error
|
|
430
|
+
* in the rebase's replay, and it is not zero even on a loopback: the stamp is the predictor's
|
|
431
|
+
* *head* (authority + lead), while the server applies on arrival, at a tick barely past the
|
|
432
|
+
* authority the client is replaying from. Replaying at the stamp therefore held the previous
|
|
433
|
+
* intent for `gap` ticks the server had already simulated under the new one — the overshoot on
|
|
434
|
+
* every key release, sized `gap x speed`.
|
|
435
|
+
*
|
|
436
|
+
* Smoothed, because it rides on delivery jitter and the measurement is one sample per judged
|
|
437
|
+
* write. Clamped into `[0, RESIM_DEPTH]`: a stamp taken before this client had any physics
|
|
438
|
+
* authority is on the session's bare write counter rather than the server's tick stream, and
|
|
439
|
+
* differencing the two clocks is meaningless — 0 is the old behaviour and the honest default.
|
|
440
|
+
*/
|
|
441
|
+
noteWriteApplied(stampTick: number, appliedTick: number): void;
|
|
342
442
|
/** Does the local world currently simulate `collection[id]`? */
|
|
343
443
|
has(collection: string, id: string): boolean;
|
|
344
444
|
/**
|
|
@@ -355,11 +455,88 @@ declare class PhysicsPredictor {
|
|
|
355
455
|
* draw loop — or a bot's render sampling — is the clock; there is no timer.
|
|
356
456
|
*/
|
|
357
457
|
frame(now: number): void;
|
|
458
|
+
/**
|
|
459
|
+
* The head moved: store every predicted body's pose under the tick it now holds.
|
|
460
|
+
*
|
|
461
|
+
* A tick can be simulated more than once — a `DELTA` and a `CORRECT` both arrive for the same
|
|
462
|
+
* tick and each rebases — so the slot is simply rewritten with the better answer. It can also
|
|
463
|
+
* move backwards, when a free-run step is discarded by the rebase behind it; `curTick` does
|
|
464
|
+
* not follow it down, because moving the newest label backwards would drag the segment the
|
|
465
|
+
* renderer is crossing backwards with it.
|
|
466
|
+
*/
|
|
467
|
+
private capturePoses;
|
|
468
|
+
/**
|
|
469
|
+
* Advance the render clock by one frame of wall time, steering it towards a target distance
|
|
470
|
+
* behind the head rather than clamping it there.
|
|
471
|
+
*
|
|
472
|
+
* `renderTick` and `curTick` agree on rate — one tick per tick period — but not on phase, and
|
|
473
|
+
* the phase error is delivery jitter: authority arrives when the network hands it over, not
|
|
474
|
+
* every 33 ms however steady the server is. Left alone the two would wander apart, so the
|
|
475
|
+
* clock is steered, by rate rather than by position. What the network did to a packet is not
|
|
476
|
+
* something the player's character should be seen doing.
|
|
477
|
+
*/
|
|
478
|
+
private advanceRenderClock;
|
|
479
|
+
/**
|
|
480
|
+
* Keep the render clock inside the span there are poses for.
|
|
481
|
+
*
|
|
482
|
+
* Both ends are real. At `curTick` there is nothing further to draw towards, so a late arrival
|
|
483
|
+
* holds the pose rather than extrapolating motion the next tick would have to take back — the
|
|
484
|
+
* same trade that sized the prediction lead (bug 1). At the far end the ring has recycled the
|
|
485
|
+
* slot and the pose is genuinely gone. Between them the clock floats, and the rate trim above
|
|
486
|
+
* is what keeps it from spending its time against either stop.
|
|
487
|
+
*/
|
|
488
|
+
private holdRenderTick;
|
|
489
|
+
/** `smoothingHalfLifeMs`, or 0 when the game turned the smoothing off. */
|
|
490
|
+
private halfLifeMs;
|
|
491
|
+
/** Remember where every body is being drawn, before this frame re-simulates anything. */
|
|
492
|
+
private snapshotDrawn;
|
|
493
|
+
/**
|
|
494
|
+
* Take whatever the re-simulation moved the drawn pose by and put it in the offset instead.
|
|
495
|
+
*
|
|
496
|
+
* This is the whole of correction smoothing, and it is smoothing the *error*: the difference
|
|
497
|
+
* measured here is between two answers to the same question — where is this body at the render
|
|
498
|
+
* clock — asked either side of a rebase. Motion the simulation produced is not in it, because
|
|
499
|
+
* the render clock advanced before the snapshot. So real movement is never slowed, which is
|
|
500
|
+
* the thing a rate limiter on the drawn position cannot promise: it sees a position that moved
|
|
501
|
+
* and has no way to know whether the body ran or was corrected.
|
|
502
|
+
*
|
|
503
|
+
* It absorbs more than server disagreement. A newly flushed intent changes what the last few
|
|
504
|
+
* ticks should have been, and the replay hands that over as a jump the same way; so does the
|
|
505
|
+
* render clock being pulled back inside the poses it has. All of it is the same defect from
|
|
506
|
+
* the player's side — the character was drawn somewhere it now turns out it was not — and all
|
|
507
|
+
* of it eases away here.
|
|
508
|
+
*/
|
|
509
|
+
private absorbJump;
|
|
510
|
+
/**
|
|
511
|
+
* Ease every offset towards zero. Exponential, so the rate is proportional to the error and
|
|
512
|
+
* there is no world-scale speed to pick: a correction worth a tenth of a unit is worked off
|
|
513
|
+
* gently and one worth a whole unit is not left hanging around for a second.
|
|
514
|
+
*/
|
|
515
|
+
private decayOffsets;
|
|
516
|
+
private clearOffset;
|
|
517
|
+
/**
|
|
518
|
+
* The pose to draw one body at: the stored poses either side of the render clock, blended.
|
|
519
|
+
* Falls back to the body's live transform when the ring cannot bracket it — before the first
|
|
520
|
+
* capture, and for a body that appeared this frame.
|
|
521
|
+
*/
|
|
522
|
+
private rawPose;
|
|
523
|
+
/** `rawPose` with the render-error offset applied — what the draw loop actually gets. */
|
|
524
|
+
private renderPose;
|
|
358
525
|
/**
|
|
359
526
|
* The predicted record for `collection[id]`: the authoritative record (which already carries
|
|
360
527
|
* local intent writes — `track()` writes through to plain state) with the body-mapped channels
|
|
361
528
|
* replaced by the local world's values, `Math.fround`ed for f32 fields so what the draw loop
|
|
362
529
|
* reads is what the wire would carry.
|
|
530
|
+
*
|
|
531
|
+
* Drawn *between* two simulated ticks, not at the head. The local world steps at the room's
|
|
532
|
+
* tick rate, and only when authority arrives to rebase it; a display runs at two to four times
|
|
533
|
+
* that and asks on every frame. Handing back the head means the answer changes on some frames
|
|
534
|
+
* and not others, so a character running at a steady nine units a second is drawn standing
|
|
535
|
+
* still for half of them and moving at twenty for the rest. It is the player's own character
|
|
536
|
+
* answering their own key, and it reads as the game hitching.
|
|
537
|
+
*
|
|
538
|
+
* This is a *render* read. `room.state` and every correctness path — `predictedValues`, the
|
|
539
|
+
* per-tick history a correction is judged against — still see the head exactly as before.
|
|
363
540
|
*/
|
|
364
541
|
read(desc: CollectionDesc, id: string, base: AnyRecord$2): AnyRecord$2;
|
|
365
542
|
/**
|
|
@@ -388,11 +565,43 @@ declare class PhysicsPredictor {
|
|
|
388
565
|
private warnOverCap;
|
|
389
566
|
private warnOnce;
|
|
390
567
|
/**
|
|
391
|
-
* The client's lead over authority, in ticks: one-way
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
568
|
+
* The client's lead over authority, in ticks: the one-way transit the authority in hand has
|
|
569
|
+
* already spent in flight, rounded to the nearest tick. Nothing more.
|
|
570
|
+
*
|
|
571
|
+
* Every tick of lead beyond that transit is a tick of motion the client draws on the
|
|
572
|
+
* assumption that the input it is holding will still be held when the server gets there. On a
|
|
573
|
+
* release that assumption is wrong by construction, and the invented travel is handed back as
|
|
574
|
+
* a backwards yank — bug 1's "keeps moving after key up and then snaps back". It was
|
|
575
|
+
* `ceil(owd) + 1`, which on a fast link is two whole ticks of margin over a transit of nearly
|
|
576
|
+
* zero. Measured on the arena fixture at no injected latency (release overshoot of a 6 u/s
|
|
577
|
+
* held-input character, `physics-predict.test.ts`):
|
|
578
|
+
*
|
|
579
|
+
* | lead | drawn overshoot past the server's stop | worst backwards step |
|
|
580
|
+
* | --- | --- | --- |
|
|
581
|
+
* | `ceil(owd) + 1` (was) | 0.585 u | -0.585 u |
|
|
582
|
+
* | `max(1, round(owd))` (is) | 0.293 u | -0.292 u |
|
|
583
|
+
* | `round(owd)`, floor 0 | 0.0008 u | -0.0008 u |
|
|
584
|
+
*
|
|
585
|
+
* One tick of running is 0.3 u there: the overshoot is the lead, in ticks, and nothing else.
|
|
586
|
+
* On a link with no transit to cover, every tick of lead is a tick of guessing that the key is
|
|
587
|
+
* still held.
|
|
588
|
+
*
|
|
589
|
+
* The floor of 1 is not margin, it is the smallest lead that is still prediction. At 0 the
|
|
590
|
+
* rebase takes no steps: the local world is pinned to the authority it just received, which is
|
|
591
|
+
* already a tick old, so the client responds to its own input only when the next `CORRECT`
|
|
592
|
+
* arrives and every moving body reads as mispredicted by one tick of motion. Measured: the
|
|
593
|
+
* bot suites' correction-storm invariant fires immediately (21 corrections/s per bot against a
|
|
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.
|
|
396
605
|
*/
|
|
397
606
|
private leadTicks;
|
|
398
607
|
private timestepMs;
|
|
@@ -416,6 +625,7 @@ declare class PhysicsPredictor {
|
|
|
416
625
|
stampTick(): number | undefined;
|
|
417
626
|
/** After every step: advance each owned body's prediction clock and remember its channels. */
|
|
418
627
|
private recordStep;
|
|
628
|
+
/** Returns the number of steps taken, so the caller knows whether the head moved. */
|
|
419
629
|
private freeRun;
|
|
420
630
|
/**
|
|
421
631
|
* Applies intents for every owned predicted body before one step: the intent in force at the
|
|
@@ -634,6 +844,20 @@ type MessageTarget = 'all' | string | {
|
|
|
634
844
|
declare const MAX_PREDICTED_BODIES = 64;
|
|
635
845
|
/** Default correction-suppression epsilon, world units (see `ClientPhysicsOptions.epsilon`). */
|
|
636
846
|
declare const PREDICTION_EPSILON = 0.05;
|
|
847
|
+
/**
|
|
848
|
+
* Default half-life for render-error smoothing, milliseconds (see
|
|
849
|
+
* `ClientPhysicsOptions.smoothingHalfLifeMs`). About two ticks at 30Hz: long enough that a
|
|
850
|
+
* correction is a settle rather than a step, short enough that the drawn position is back on the
|
|
851
|
+
* simulation within a fifth of a second.
|
|
852
|
+
*/
|
|
853
|
+
declare const SMOOTHING_HALF_LIFE_MS = 70;
|
|
854
|
+
/**
|
|
855
|
+
* Default distance past which a render-error offset is dropped rather than eased away, world
|
|
856
|
+
* units (see `ClientPhysicsOptions.smoothingSnapUnits`). A respawn, an area change or a session
|
|
857
|
+
* resync moves a body somewhere else entirely, and sliding into it is worse than arriving.
|
|
858
|
+
* Four units is a guess at world scale and the one number here a game may need to change.
|
|
859
|
+
*/
|
|
860
|
+
declare const SMOOTHING_SNAP_UNITS = 4;
|
|
637
861
|
/** `room.prediction` (D22 part 2). */
|
|
638
862
|
interface PredictionStatus {
|
|
639
863
|
/** The engine is loaded and the local world exists. */
|
|
@@ -655,7 +879,10 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
|
|
|
655
879
|
readonly me: string;
|
|
656
880
|
/** The room code (from `WELCOME`). */
|
|
657
881
|
readonly id: string;
|
|
658
|
-
/**
|
|
882
|
+
/**
|
|
883
|
+
* Shareable URL carrying `?room=<id>`, with per-client params (`role`) stripped so it is safe
|
|
884
|
+
* to hand to somebody else. The address bar keeps them.
|
|
885
|
+
*/
|
|
659
886
|
readonly link: string;
|
|
660
887
|
/** The last server tick this client saw. */
|
|
661
888
|
readonly tick: number;
|
|
@@ -666,7 +893,9 @@ interface Room<S, Role extends string = RoleOf<S> & string> {
|
|
|
666
893
|
/**
|
|
667
894
|
* The render read path (D20): same shapes as `room.state`, but non-owned entities are
|
|
668
895
|
* interpolated at `now − interpDelayMs` (numeric fields lerp, everything else steps) while
|
|
669
|
-
*
|
|
896
|
+
* predicted bodies are drawn between the two most recent ticks of the local physics world.
|
|
897
|
+
* Either way the value advances with the frame clock rather than with arrivals or with the
|
|
898
|
+
* tick rate. A draw loop opts in by one substitution —
|
|
670
899
|
* `room.state.players` → `room.render.players`. Tests and game logic keep reading
|
|
671
900
|
* `room.state`, which stays authoritative.
|
|
672
901
|
*/
|
|
@@ -757,8 +986,10 @@ declare const webSocketTransport: Transport;
|
|
|
757
986
|
* The interpolated render read path (D20, week 8): `room.render.<collection>` mirrors
|
|
758
987
|
* `room.state`'s shapes, but non-owned entities are rendered at `now − interpDelayMs`, lerping
|
|
759
988
|
* numeric fields between the two bracketing DELTAs and stepping everything else (strings, bools,
|
|
760
|
-
* enums, refs, lists).
|
|
761
|
-
*
|
|
989
|
+
* enums, refs, lists). Predicted bodies — owned or `predicted: true` — come from the local
|
|
990
|
+
* physics world instead, drawn between the two most recent simulated ticks rather than at the
|
|
991
|
+
* simulation head, so they too advance with the frame clock and not with whatever rate their
|
|
992
|
+
* source happens to update at (`physics.ts`, `read`). One read path serves the whole draw loop.
|
|
762
993
|
*
|
|
763
994
|
* `room.state` stays authoritative and untouched: tests, bots and existing code see exactly what
|
|
764
995
|
* they saw before. Entities appear and disappear at the buffered time; past the newest delta the
|
|
@@ -820,8 +1051,13 @@ type AnyRecord = Record<string, unknown>;
|
|
|
820
1051
|
type ClientImpl = (params: AnyRecord) => unknown;
|
|
821
1052
|
/** Default hard cap on the owned-write flush window. */
|
|
822
1053
|
declare const DEFAULT_WRITE_INTERVAL_MS = 50;
|
|
823
|
-
/**
|
|
824
|
-
|
|
1054
|
+
/**
|
|
1055
|
+
* How often the client pings for `room.rtt`. Two seconds rather than ten (bug 4): the prediction
|
|
1056
|
+
* lead is re-derived from `rtt` on every rebase, so the interval is not just how fresh a
|
|
1057
|
+
* diagnostic number is — it is how long one measurement gets to size the client's simulation.
|
|
1058
|
+
* A PING is a 4-byte payload; five of them a minute is not the reason a room costs anything.
|
|
1059
|
+
*/
|
|
1060
|
+
declare const PING_INTERVAL_MS = 2000;
|
|
825
1061
|
/** How long an outbound RPC waits for its `REPLY` before rejecting. */
|
|
826
1062
|
declare const CALL_TIMEOUT_MS = 10000;
|
|
827
1063
|
interface SessionOptions {
|
|
@@ -1008,4 +1244,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
|
|
|
1008
1244
|
*/
|
|
1009
1245
|
declare function joinRelay(options?: JoinRelayOptions): Promise<RelayRoom>;
|
|
1010
1246
|
|
|
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 };
|
|
1247
|
+
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, 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 };
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
MAX_PREDICTED_BODIES,
|
|
5
5
|
PREDICTION_EPSILON,
|
|
6
6
|
RESIM_DEPTH,
|
|
7
|
+
SMOOTHING_HALF_LIFE_MS,
|
|
8
|
+
SMOOTHING_SNAP_UNITS,
|
|
7
9
|
emptyPredictionStats
|
|
8
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-7UTJ7RSF.js";
|
|
9
11
|
|
|
10
12
|
// src/endpoint.ts
|
|
11
13
|
var DEFAULT_REGION = "eu";
|
|
@@ -61,15 +63,19 @@ function roomIdFromLocation() {
|
|
|
61
63
|
return "";
|
|
62
64
|
}
|
|
63
65
|
}
|
|
66
|
+
var PER_CLIENT_PARAMS = ["role"];
|
|
64
67
|
function publishRoomToLocation(roomId) {
|
|
65
68
|
const loc = currentLocation();
|
|
66
69
|
const history = globalThis.history;
|
|
67
70
|
if (!loc) return void 0;
|
|
68
71
|
let href;
|
|
72
|
+
let share;
|
|
69
73
|
try {
|
|
70
74
|
const url = new URL(loc.href);
|
|
71
75
|
url.searchParams.set("room", roomId);
|
|
72
76
|
href = url.href;
|
|
77
|
+
for (const param of PER_CLIENT_PARAMS) url.searchParams.delete(param);
|
|
78
|
+
share = url.href;
|
|
73
79
|
} catch {
|
|
74
80
|
return void 0;
|
|
75
81
|
}
|
|
@@ -79,7 +85,7 @@ function publishRoomToLocation(roomId) {
|
|
|
79
85
|
} catch {
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
|
-
return
|
|
88
|
+
return share;
|
|
83
89
|
}
|
|
84
90
|
function linkForUrl(wsUrl, roomId) {
|
|
85
91
|
try {
|
|
@@ -114,6 +120,7 @@ import {
|
|
|
114
120
|
encodeReply,
|
|
115
121
|
errorByCode,
|
|
116
122
|
formatError,
|
|
123
|
+
readCorrectAppliedTick,
|
|
117
124
|
readCorrectClientTick,
|
|
118
125
|
relaySchema,
|
|
119
126
|
rpcTable,
|
|
@@ -479,7 +486,8 @@ var webSocketTransport = {
|
|
|
479
486
|
|
|
480
487
|
// src/session.ts
|
|
481
488
|
var DEFAULT_WRITE_INTERVAL_MS = 50;
|
|
482
|
-
var PING_INTERVAL_MS =
|
|
489
|
+
var PING_INTERVAL_MS = 2e3;
|
|
490
|
+
var RTT_ALPHA = 0.3;
|
|
483
491
|
var CALL_TIMEOUT_MS = 1e4;
|
|
484
492
|
var BACKOFF_START_MS = 250;
|
|
485
493
|
var BACKOFF_MAX_MS = 5e3;
|
|
@@ -518,7 +526,7 @@ var Session = class {
|
|
|
518
526
|
if (options.physics && hasPhysics) {
|
|
519
527
|
const physics = options.physics;
|
|
520
528
|
this.predictionRequested = true;
|
|
521
|
-
void import("./physics-
|
|
529
|
+
void import("./physics-H2VDQLAU.js").then(({ PhysicsPredictor }) => {
|
|
522
530
|
if (this.left) return;
|
|
523
531
|
const predictor = new PhysicsPredictor(
|
|
524
532
|
this.ext,
|
|
@@ -836,7 +844,7 @@ var Session = class {
|
|
|
836
844
|
this.store.applyServerDelta(delta);
|
|
837
845
|
this.render.recordDelta(delta);
|
|
838
846
|
if (this.predictor && delta.collections.some((dc) => this.predictor?.predictsCollection(dc.name))) {
|
|
839
|
-
this.predictor.noteAuthority();
|
|
847
|
+
this.predictor.noteAuthority(delta.tick);
|
|
840
848
|
this.predictor.frame(this.scheduler.now());
|
|
841
849
|
}
|
|
842
850
|
}
|
|
@@ -844,6 +852,10 @@ var Session = class {
|
|
|
844
852
|
const r = new ByteReader(payload);
|
|
845
853
|
const delta = decodeDeltaFrom(this.ext, r);
|
|
846
854
|
const clientTick = readCorrectClientTick(r);
|
|
855
|
+
const appliedTick = readCorrectAppliedTick(r);
|
|
856
|
+
if (clientTick !== void 0 && appliedTick !== void 0) {
|
|
857
|
+
this.predictor?.noteWriteApplied(clientTick, appliedTick);
|
|
858
|
+
}
|
|
847
859
|
this.tick = delta.tick;
|
|
848
860
|
const predicted = this.capturePredicted(delta);
|
|
849
861
|
let sawBodyFields = false;
|
|
@@ -870,7 +882,7 @@ var Session = class {
|
|
|
870
882
|
});
|
|
871
883
|
}
|
|
872
884
|
if (sawBodyFields && this.predictor) {
|
|
873
|
-
this.predictor.noteAuthority();
|
|
885
|
+
this.predictor.noteAuthority(delta.tick);
|
|
874
886
|
this.predictor.frame(this.scheduler.now());
|
|
875
887
|
}
|
|
876
888
|
}
|
|
@@ -916,7 +928,8 @@ var Session = class {
|
|
|
916
928
|
}
|
|
917
929
|
onPong(payload) {
|
|
918
930
|
const pong = decodePong(payload);
|
|
919
|
-
|
|
931
|
+
const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
|
|
932
|
+
this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
|
|
920
933
|
if (pong.serverTick > this.tick) this.tick = pong.serverTick;
|
|
921
934
|
}
|
|
922
935
|
onMsg(payload) {
|
|
@@ -1310,6 +1323,8 @@ export {
|
|
|
1310
1323
|
PING_INTERVAL_MS,
|
|
1311
1324
|
PREDICTION_EPSILON,
|
|
1312
1325
|
RESIM_DEPTH,
|
|
1326
|
+
SMOOTHING_HALF_LIFE_MS,
|
|
1327
|
+
SMOOTHING_SNAP_UNITS,
|
|
1313
1328
|
Session,
|
|
1314
1329
|
defaultScheduler,
|
|
1315
1330
|
joinRelay,
|