svelte-realtime 0.6.0-next.73 → 0.6.0-next.74
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/README.md +4 -1
- package/package.json +1 -1
- package/src/server/interest.js +81 -2
- package/src/server/smooth.js +73 -5
- package/src/server.d.ts +13 -1
package/README.md
CHANGED
|
@@ -3644,11 +3644,14 @@ export const arena = live.smooth({
|
|
|
3644
3644
|
{ within: 400, rate: 1 }, // near: every tick
|
|
3645
3645
|
{ within: 800, rate: 3 }, // mid: every 3rd tick
|
|
3646
3646
|
{ within: 1200, rate: 8 } // fringe: every 8th tick
|
|
3647
|
-
]
|
|
3647
|
+
],
|
|
3648
|
+
budget: 64 // optional per-subscriber delivery ceiling (entities per tick)
|
|
3648
3649
|
}
|
|
3649
3650
|
});
|
|
3650
3651
|
```
|
|
3651
3652
|
|
|
3653
|
+
`budget` puts a hard per-subscriber ceiling on how many entities one tick delivers: past it the due set trims farthest-first (band, then distance - the fringe fades before the duel next to you), and a trimmed entity stays owed, delivering the moment the budget frees, so motion is throttled, never lost. The ceiling also tightens itself for a subscriber whose socket is backpressured - the server reads each connection's outbound queue as it culls, scaling the budget down smoothly (to a floor of one) as the queue approaches the transport's shedding limit - so a congested client sheds its fringe gracefully instead of having the transport drop arbitrary frames, and a lagging shooter's rewind reach widens automatically with its real delivery cadence. Always-visible entities ride above the ceiling. Per-client interest mode only (`cells` mode fans out per cell and has no per-subscriber walk to bound - combining them throws).
|
|
3654
|
+
|
|
3652
3655
|
The area-of-interest centre is the subscriber's own entity by default, so a player-centric game needs no extra wiring. A spectator or free-cam whose view is not its own entity calls `view.reportCenter(x, y)` to point culling at where the camera looks (and `view.clearCenter()` to revert); the report rides a volatile send, an unchanged centre is dropped, and it takes effect even on a still board, so panning across a paused scene reveals what is there.
|
|
3653
3656
|
|
|
3654
3657
|
> **Authoritative games: gate the centre report.** By default reports are ungated (`centerPolicy: 'any'`) - deliberately, because on self-selecting surfaces (cursors, canvases, geo-dashboards) choosing your own view IS the feature. On a server-authoritative game topic, though, an ungated report is a radar cheat: a modified client recenters replication anywhere on the map. Set `interest: { centerPolicy: 'own-entity' }` to clamp every connection that owns a positioned entity to that entity (spectators - connections whose entity resolves no position - keep the free-cam), or pass a callback `(ctx, center, ownPos) => boolean | { x, y }` to decide per report (return a point to clamp instead of rejecting; a throw rejects). A rejected report behaves like no report, a previously-accepted override is dropped, and clearing is always allowed. It applies identically in cells mode, where the report drives the cell subscriptions themselves. `lod` bands let distant motion fade at a throttled, id-staggered cadence instead of consuming the same bandwidth as a nearby duel; a small hysteresis margin on the band edges stops an entity hovering on a ring from flickering, and an entity entering range (or crossing into a nearer band) is always delivered at once. Relevancy is a delivery preference, never authorization - the `guard` stays the separate auth layer - and the polarity is to over-deliver: a subscriber with no resolvable centre is delivered the whole board. The join snapshot is scoped the same way: a syncing subscriber receives the in-range roster plus the always-visible entities (and always its own entity, its reconciliation basis), not the whole board - entities it later moves toward are caught up on first sight. **Off by default**, gated end to end on `interest != null`, so a topic without it is byte-identical to the broadcast-all path (the gate overhead benchmarks within noise, well under 1%). Culling re-sends an entity only when it actually moved since a subscriber last saw it, which relies on `apply` returning a NEW state object on a change rather than mutating in place - the same contract the broadcast itself already depends on (an in-place mutation makes the authority think nothing changed). At arena scale the cull delivers roughly 90% less per client, and the saving grows with the population. It works single-instance and across the cluster: a topic's owner culls its own local subscribers against the full cluster-wide catalog, and every other instance runs the same cull on the receive side - it keeps a shadow catalog (seeded from the owner's cold-join snapshot so a stationary in-range entity is never missed, then updated from inbound relays) and delivers each of its local subscribers only the relayed updates inside its area of interest. So the last hop to a remote player is culled too, never over-delivered. The inter-instance relay itself still carries every update between instances (the owner cannot cull per remote subscriber, since it does not hold a remote subscriber's centre); trimming that bus traffic is a separate future optimization.
|
package/package.json
CHANGED
package/src/server/interest.js
CHANGED
|
@@ -219,6 +219,25 @@ export function createInterestState(interest) {
|
|
|
219
219
|
const subSet = new Set();
|
|
220
220
|
/** @type {number[]} empty always-visible list for candidatesAt (ring-less entities are never hit candidates) */
|
|
221
221
|
const noAlwaysVisible = [];
|
|
222
|
+
// Per-subscriber budget scratch (pooled; touched only when a delivery budget
|
|
223
|
+
// is active for the subscriber, so the uncapped path allocates nothing). The
|
|
224
|
+
// due banded entries collect here so an over-budget tick can trim the
|
|
225
|
+
// farthest ones; parallel arrays keep the scan allocation-free.
|
|
226
|
+
/** @type {string[]} */
|
|
227
|
+
const dueKeys = [];
|
|
228
|
+
/** @type {number[]} */
|
|
229
|
+
const dueBand = [];
|
|
230
|
+
/** @type {number[]} */
|
|
231
|
+
const dueD2 = [];
|
|
232
|
+
/** @type {number[]} previous band per due entry (-1 = first sight) */
|
|
233
|
+
const duePrevBand = [];
|
|
234
|
+
/** @type {any[]} previous last-sent state per due entry */
|
|
235
|
+
const duePrevSent = [];
|
|
236
|
+
/** @type {number[]} sortable index scratch for the over-budget trim */
|
|
237
|
+
const dueOrder = [];
|
|
238
|
+
/** Nearest-first order for the trim: band, then distance, then key (deterministic). */
|
|
239
|
+
const dueCompare = (a, b) =>
|
|
240
|
+
dueBand[a] - dueBand[b] || dueD2[a] - dueD2[b] || (dueKeys[a] < dueKeys[b] ? -1 : 1);
|
|
222
241
|
|
|
223
242
|
/**
|
|
224
243
|
* Record a subscriber's reported area-of-interest center (the optional
|
|
@@ -303,12 +322,30 @@ export function createInterestState(interest) {
|
|
|
303
322
|
* a fringe entity's motion accumulated across throttled ticks is flushed on its
|
|
304
323
|
* next due tick whether or not it happened to move on that exact tick.
|
|
305
324
|
*
|
|
325
|
+
* When a delivery budget is active for a subscriber (`budgetOf` returns a
|
|
326
|
+
* finite count), at most that many BANDED entities are delivered to it this
|
|
327
|
+
* tick: the due set is trimmed farthest-first (band, then distance - the
|
|
328
|
+
* fringe demotes before the action around the player), and a trimmed entry's
|
|
329
|
+
* LOD record is reverted to its pre-tick value, so the entity stays due and
|
|
330
|
+
* delivers as soon as the budget frees - throttled, never lost. Always-visible
|
|
331
|
+
* entities and whole-board (uncentered) subscribers bypass the budget: the
|
|
332
|
+
* first are an explicit app statement, the second is the over-deliver safety
|
|
333
|
+
* polarity, and neither has a distance to trim by. The candidate membership
|
|
334
|
+
* (`getCandidates`) is untouched by a trim - an entity delivered earlier is
|
|
335
|
+
* still on the shooter's screen and stays hittable; one never delivered never
|
|
336
|
+
* entered the membership at all.
|
|
337
|
+
*
|
|
306
338
|
* @param {Array<{ key: string, state: any }>} catalog the authority catalog
|
|
307
339
|
* @param {Iterable<string>} subscribers the local subscriber identities
|
|
308
340
|
* @param {number} tick a monotonic integer tick counter (drives the LOD cadence)
|
|
341
|
+
* @param {(identity: string) => number | undefined} [budgetOf] the effective
|
|
342
|
+
* per-subscriber delivery budget for this tick (already scaled by whatever
|
|
343
|
+
* pressure signal the caller reads); a non-finite / undefined return means
|
|
344
|
+
* uncapped. Kept as an injected read so this pass stays clock- and I/O-free.
|
|
309
345
|
* @returns {Map<string, Set<string>>} identity -> entity keys to deliver this tick
|
|
310
346
|
*/
|
|
311
|
-
function compute(catalog, subscribers, tick) {
|
|
347
|
+
function compute(catalog, subscribers, tick, budgetOf) {
|
|
348
|
+
const budgeted = typeof budgetOf === 'function';
|
|
312
349
|
const n = catalog.length;
|
|
313
350
|
|
|
314
351
|
// 1. Resolve every entity's position once; collect the always-visible ones.
|
|
@@ -358,6 +395,14 @@ export function createInterestState(interest) {
|
|
|
358
395
|
if (lodForS === undefined) { lodForS = new Map(); lod.set(identity, lodForS); }
|
|
359
396
|
const relevant = new Set();
|
|
360
397
|
seen.clear();
|
|
398
|
+
// Resolve this subscriber's effective delivery budget once per tick.
|
|
399
|
+
let cap = Infinity;
|
|
400
|
+
if (budgeted) {
|
|
401
|
+
const b = budgetOf(identity);
|
|
402
|
+
if (typeof b === 'number' && Number.isFinite(b) && b >= 1) cap = Math.floor(b);
|
|
403
|
+
}
|
|
404
|
+
const capped = cap !== Infinity;
|
|
405
|
+
let dueCount = 0;
|
|
361
406
|
|
|
362
407
|
// 4. The candidate set is the in-radius cull when centered, or every entity
|
|
363
408
|
// when whole-board. Each candidate is delivered only when its state changed
|
|
@@ -398,9 +443,43 @@ export function createInterestState(interest) {
|
|
|
398
443
|
// so a stationary in-range entity is never re-sent.
|
|
399
444
|
const cadence = prevBand < 0 || band !== prevBand || rate <= 1 || tick % rate === hashes[idx] % rate;
|
|
400
445
|
const due = changed && cadence;
|
|
401
|
-
if (due)
|
|
446
|
+
if (due) {
|
|
447
|
+
relevant.add(key);
|
|
448
|
+
if (capped) {
|
|
449
|
+
// Record what the trim below needs: the sort keys (band, d2)
|
|
450
|
+
// and the pre-tick LOD record to revert a trimmed entry to.
|
|
451
|
+
dueKeys[dueCount] = key;
|
|
452
|
+
dueBand[dueCount] = band;
|
|
453
|
+
dueD2[dueCount] = d2;
|
|
454
|
+
duePrevBand[dueCount] = prevBand;
|
|
455
|
+
duePrevSent[dueCount] = prevSent;
|
|
456
|
+
dueCount++;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
402
459
|
lodForS.set(key, { band, sent: due ? state : prevSent });
|
|
403
460
|
}
|
|
461
|
+
// Over budget: keep the `cap` nearest due entries (band, then distance,
|
|
462
|
+
// then key - fully deterministic) and revert the rest to their pre-tick
|
|
463
|
+
// LOD record, so a trimmed entity stays due and delivers when the budget
|
|
464
|
+
// frees. A reverted first-sight entry is REMOVED (it was never delivered,
|
|
465
|
+
// so it must not enter the candidate membership); a reverted known entry
|
|
466
|
+
// keeps its old band, so a dropped band-crossing re-detects and delivers
|
|
467
|
+
// promptly once there is room.
|
|
468
|
+
if (capped && dueCount > cap) {
|
|
469
|
+
dueOrder.length = dueCount;
|
|
470
|
+
for (let i = 0; i < dueCount; i++) dueOrder[i] = i;
|
|
471
|
+
dueOrder.sort(dueCompare);
|
|
472
|
+
for (let i = cap; i < dueCount; i++) {
|
|
473
|
+
const j = dueOrder[i];
|
|
474
|
+
const key = dueKeys[j];
|
|
475
|
+
relevant.delete(key);
|
|
476
|
+
if (duePrevBand[j] < 0) lodForS.delete(key);
|
|
477
|
+
else lodForS.set(key, { band: duePrevBand[j], sent: duePrevSent[j] });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Release the pooled state references so a departed entity's last state
|
|
481
|
+
// is not retained by the scratch until the next capped tick.
|
|
482
|
+
for (let i = 0; i < dueCount; i++) duePrevSent[i] = undefined;
|
|
404
483
|
// Forget entities that left this subscriber's range, so a re-entry is a
|
|
405
484
|
// fresh first-sight (delivered at once) and the map stays bounded by the
|
|
406
485
|
// live in-range set rather than every entity ever seen.
|
package/src/server/smooth.js
CHANGED
|
@@ -277,6 +277,45 @@ const _SMOOTH_SNAPSHOT_MS = 1000;
|
|
|
277
277
|
*/
|
|
278
278
|
const _SMOOTH_PENDING_GRACE_MS = 30000;
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The outbound-queue window that scales a subscriber's `interest.budget` down.
|
|
282
|
+
* Below LOW (the adapter pressure sampler's "notable queue" bar) the full budget
|
|
283
|
+
* applies; at HIGH (the uWS default `maxBackpressure`, where the adapter starts
|
|
284
|
+
* shedding frames outright) the budget floors at 1 - the innermost entity still
|
|
285
|
+
* flows, and a socket this wedged is about to be degraded by the transport
|
|
286
|
+
* anyway. Linear in between: congestion sheds the fringe first, smoothly,
|
|
287
|
+
* instead of letting the transport drop arbitrary frames at the cliff.
|
|
288
|
+
*/
|
|
289
|
+
const _SMOOTH_BUDGET_BUFFERED_LOW = 65536;
|
|
290
|
+
const _SMOOTH_BUDGET_BUFFERED_HIGH = 1048576;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build the per-tick effective-budget reader for a topic with `interest.budget`
|
|
294
|
+
* set: the configured ceiling, scaled by the subscriber socket's CURRENT
|
|
295
|
+
* outbound queue depth. The read is per subscriber per tick (one native
|
|
296
|
+
* `getBufferedAmount` getter - the same accessor the adapter's pressure sampler
|
|
297
|
+
* walks); a platform whose sockets do not expose it (the deterministic sim, a
|
|
298
|
+
* bare test double) reads as unbuffered and keeps the full budget, so the pure
|
|
299
|
+
* relevancy pass stays deterministic wherever the transport is.
|
|
300
|
+
* @param {any} rec @param {number} budget
|
|
301
|
+
* @returns {(identity: string) => number | undefined}
|
|
302
|
+
*/
|
|
303
|
+
function _smoothBudgetOf(rec, budget) {
|
|
304
|
+
return (identity) => {
|
|
305
|
+
const ws = rec.registry.get(identity);
|
|
306
|
+
if (ws === undefined) return budget;
|
|
307
|
+
let buffered = 0;
|
|
308
|
+
if (typeof ws.getBufferedAmount === 'function') {
|
|
309
|
+
try { buffered = ws.getBufferedAmount(); } catch { buffered = 0; }
|
|
310
|
+
}
|
|
311
|
+
if (!(typeof buffered === 'number' && Number.isFinite(buffered)) || buffered <= _SMOOTH_BUDGET_BUFFERED_LOW) return budget;
|
|
312
|
+
if (buffered >= _SMOOTH_BUDGET_BUFFERED_HIGH) return 1;
|
|
313
|
+
const scale = 1 - (buffered - _SMOOTH_BUDGET_BUFFERED_LOW) / (_SMOOTH_BUDGET_BUFFERED_HIGH - _SMOOTH_BUDGET_BUFFERED_LOW);
|
|
314
|
+
const b = Math.floor(budget * scale);
|
|
315
|
+
return b >= 1 ? b : 1;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
280
319
|
/**
|
|
281
320
|
* One-shot dev guard. When live.smooth() runs on a platform that does not
|
|
282
321
|
* implement the binary wire (`publishWire`/`sendWire`), the tick degrades to
|
|
@@ -458,6 +497,10 @@ export function _smoothRecord(name, cfg, platform, rt) {
|
|
|
458
497
|
// on - it closes over the record, so it cannot be built inside this
|
|
459
498
|
// literal. Null otherwise (the OFF-path shape is unchanged).
|
|
460
499
|
aoi: null,
|
|
500
|
+
// The per-subscriber effective-budget reader (interest.budget), assigned
|
|
501
|
+
// right below when the budget is set - same closes-over-the-record
|
|
502
|
+
// reason as aoi. Null otherwise (uncapped, byte-identical).
|
|
503
|
+
budgetOf: null,
|
|
461
504
|
// The server world view for the onTick hook (server game logic: forces,
|
|
462
505
|
// respawns, NPC drivers), assigned right below when onTick is configured.
|
|
463
506
|
// Null otherwise - the tick pays one null check. The warn latches keep a
|
|
@@ -494,6 +537,14 @@ export function _smoothRecord(name, cfg, platform, rt) {
|
|
|
494
537
|
// hitTest reads candidates / radius / interp-delay through one accessor so
|
|
495
538
|
// the shot handlers never branch on the interest mode (per-client vs cells).
|
|
496
539
|
if (cfg.hitTest) rec.aoi = _smoothAoi(rec);
|
|
540
|
+
// Per-subscriber delivery ceiling (interest.budget): the effective-budget
|
|
541
|
+
// reader the relevancy pass consults, scaled live by each subscriber
|
|
542
|
+
// socket's outbound queue. Null (the default) keeps the pass uncapped and
|
|
543
|
+
// byte-identical. Closes over the record (it reads the live registry), so
|
|
544
|
+
// it cannot be built inside the literal - the aoi precedent above.
|
|
545
|
+
if (rec.interest !== null && cfg.interest && cfg.interest.budget) {
|
|
546
|
+
rec.budgetOf = _smoothBudgetOf(rec, cfg.interest.budget);
|
|
547
|
+
}
|
|
497
548
|
if (cfg.onTick) {
|
|
498
549
|
// Version gate: the world view needs the authority's server-entity
|
|
499
550
|
// surface. Feature-detected (not version-compared) so a custom runtime
|
|
@@ -1066,7 +1117,7 @@ function _smoothCullTick(rec) {
|
|
|
1066
1117
|
const t = rec.lagComp !== null ? (_edgeOwnerNow(rec) ?? wallEpoch()) : 0;
|
|
1067
1118
|
const catalog = [];
|
|
1068
1119
|
for (const [key, state] of rec.shadow) catalog.push({ key, state });
|
|
1069
|
-
const relevancy = rec.interest.compute(catalog, rec.registry.keys(), rec.interestTick
|
|
1120
|
+
const relevancy = rec.interest.compute(catalog, rec.registry.keys(), rec.interestTick++, rec.budgetOf ?? undefined);
|
|
1070
1121
|
_smoothDeliverCulled(rec, relevancy, rec.shadow, rec.pendingRelay, t);
|
|
1071
1122
|
rec.pendingRelay.clear();
|
|
1072
1123
|
rec.shadowDirty = false;
|
|
@@ -1195,7 +1246,7 @@ function _smoothTick(rec) {
|
|
|
1195
1246
|
const wantInterest = rec.interest !== null && (wireUpdates.length > 0 || rec.interestDirty);
|
|
1196
1247
|
const catalog = (rec.lagComp !== null || wantInterest) ? rec.authority.catalog() : null;
|
|
1197
1248
|
const relevancy = wantInterest
|
|
1198
|
-
? rec.interest.compute(catalog, rec.registry.keys(), rec.interestTick
|
|
1249
|
+
? rec.interest.compute(catalog, rec.registry.keys(), rec.interestTick++, rec.budgetOf ?? undefined)
|
|
1199
1250
|
: null;
|
|
1200
1251
|
if (rec.lagComp !== null && catalog !== null) {
|
|
1201
1252
|
// Key the ring on the monotonic axis (immune to a wall backstep); `t` (wall)
|
|
@@ -1789,8 +1840,11 @@ function _validateWire(wire) {
|
|
|
1789
1840
|
* relevancy pass consumes. `radius` and `position` are required when interest is
|
|
1790
1841
|
* on (without a resolvable position there is nothing to cull on); `lod` bands,
|
|
1791
1842
|
* when given, must be strictly ascending by `within` with an integer send-`rate`
|
|
1792
|
-
* of at least 1; `cell` tunes the spatial grid. `budget` is
|
|
1793
|
-
*
|
|
1843
|
+
* of at least 1; `cell` tunes the spatial grid. `budget` is the per-subscriber
|
|
1844
|
+
* delivery ceiling (an integer of at least 1) the per-client cull enforces,
|
|
1845
|
+
* scaled down further for a backpressured socket; it applies to the per-client
|
|
1846
|
+
* interest mode only, so combining it with `cells` (which fans out per cell,
|
|
1847
|
+
* with no per-subscriber walk to bound) is a config contradiction and throws.
|
|
1794
1848
|
* @param {any} it
|
|
1795
1849
|
*/
|
|
1796
1850
|
function _validateInterest(it) {
|
|
@@ -1809,6 +1863,14 @@ function _validateInterest(it) {
|
|
|
1809
1863
|
if (it.cells !== undefined && typeof it.cells !== 'boolean') {
|
|
1810
1864
|
throw new Error('[svelte-realtime] live.smooth() interest.cells must be a boolean');
|
|
1811
1865
|
}
|
|
1866
|
+
if (it.budget !== undefined) {
|
|
1867
|
+
if (!(typeof it.budget === 'number' && Number.isInteger(it.budget) && it.budget >= 1)) {
|
|
1868
|
+
throw new Error('[svelte-realtime] live.smooth() interest.budget must be an integer of at least 1');
|
|
1869
|
+
}
|
|
1870
|
+
if (it.cells === true) {
|
|
1871
|
+
throw new Error('[svelte-realtime] live.smooth() interest.budget applies to the per-client interest mode; cells mode fans out per cell and has no per-subscriber delivery walk to bound');
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1812
1874
|
if (
|
|
1813
1875
|
it.centerPolicy !== undefined &&
|
|
1814
1876
|
it.centerPolicy !== 'any' &&
|
|
@@ -2561,7 +2623,13 @@ export async function _smoothResolveShot(rec, name, shooterKey, shooterEntity, c
|
|
|
2561
2623
|
* `interest.lod` is an ascending list of `{ within, rate }` level-of-detail
|
|
2562
2624
|
* bands (send every `rate` ticks within that distance; the outer band's edge is
|
|
2563
2625
|
* the cull radius), `interest.cell` tunes the spatial grid, and
|
|
2564
|
-
* `interest.budget`
|
|
2626
|
+
* `interest.budget` caps how many entities are delivered to one subscriber per
|
|
2627
|
+
* tick: past the ceiling the due set trims farthest-first (fringe bands demote
|
|
2628
|
+
* before the action around the player), a trimmed entity stays due and delivers
|
|
2629
|
+
* as soon as the budget frees, and the ceiling scales down automatically for a
|
|
2630
|
+
* subscriber whose socket is backpressured - congestion sheds the fringe
|
|
2631
|
+
* smoothly instead of letting the transport drop arbitrary frames. Per-client
|
|
2632
|
+
* interest mode only (with `cells` it throws). `interest.cells` switches to
|
|
2565
2633
|
* population-scale cell-topic mode: area-of-interest becomes SUBSCRIPTION to
|
|
2566
2634
|
* grid-cell topics (one encode per cell, native fan-out) instead of a
|
|
2567
2635
|
* per-subscriber cull. `interest.centerPolicy` gates reported centers
|
package/src/server.d.ts
CHANGED
|
@@ -3239,7 +3239,19 @@ export interface SmoothInterestConfig {
|
|
|
3239
3239
|
| 'any'
|
|
3240
3240
|
| 'own-entity'
|
|
3241
3241
|
| ((ctx: LiveContext<any>, center: SmoothPoint, ownPos: SmoothPoint | null) => boolean | SmoothPoint);
|
|
3242
|
-
/**
|
|
3242
|
+
/**
|
|
3243
|
+
* Per-subscriber delivery ceiling (an integer of at least 1): at most this
|
|
3244
|
+
* many entities are delivered to one subscriber per tick. Past the ceiling
|
|
3245
|
+
* the due set trims farthest-first (band, then distance - the fringe demotes
|
|
3246
|
+
* before the action around the player); a trimmed entity stays due and
|
|
3247
|
+
* delivers as soon as the budget frees, so motion is throttled, never lost.
|
|
3248
|
+
* The ceiling scales down automatically for a subscriber whose socket is
|
|
3249
|
+
* backpressured (read from the connection's outbound queue each tick), so
|
|
3250
|
+
* congestion sheds the fringe smoothly instead of letting the transport
|
|
3251
|
+
* drop arbitrary frames at its limit. Always-visible entities (a null
|
|
3252
|
+
* `position`) bypass the ceiling. Per-client interest mode only - combining
|
|
3253
|
+
* it with `cells` throws at declaration. Off by default (uncapped).
|
|
3254
|
+
*/
|
|
3243
3255
|
budget?: number;
|
|
3244
3256
|
}
|
|
3245
3257
|
|