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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.6.0-next.73",
3
+ "version": "0.6.0-next.74",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -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) relevant.add(key);
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.
@@ -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 accepted but inert in
1793
- * this version (a reserved per-client bandwidth ceiling).
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` is reserved (inert). `interest.cells` switches to
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
- /** Reserved per-client bandwidth ceiling - accepted but inert in this version. */
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