spoint 0.1.660 → 0.1.661

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.
@@ -1,995 +1,779 @@
1
- import { MSG } from '../protocol/MessageTypes.js'
2
- import { SnapshotEncoder, unpackBinRecord, TombstoneLog, updateTombstones, PLAYER_LOD_REDUCED_HZ } from '../netcode/SnapshotEncoder.js'
3
- import { pack } from '../protocol/msgpack.js'
4
- import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js'
5
- import { applyPlayerCollisions } from '../netcode/CollisionSystem.js'
6
- import { worldToCell, packCellKey, neighborCells } from '../terrain/CubeSphereCells.js'
7
- import { createServerTimeOfDay } from './ServerTimeOfDay.js'
8
- import { createServerWeather } from './ServerWeather.js'
9
- import { enforceMovementEnvelope } from '../netcode/InputGuard.js'
10
- import { checksumBodies } from '../netcode/LockstepChecksum.js'
11
- import { recordSnapshotBytes, recordTickPhase } from './Metrics.js'
12
-
13
- const MAX_SENDS_PER_TICK = 25
14
- const INPUT_BACKLOG_DRAIN = 2
15
- const PHYSICS_PLAYER_DIVISOR = 3
16
- const PHYSICS_MAX_ACCUM_DT = 1 / 20
17
- const SNAP_UNRELIABLE = true
18
- const PRIORITY_ENTITY_BUDGET = 64
19
- const PRIORITY_DECAY = 0.02
20
- const SNAP_RATE_MIN_HZ = 8
21
- const SNAP_RATE_MAX_HZ = 30
22
- const SNAP_RATE_IDLE_HZ = 4
23
- const SNAP_RATE_ADJUST_INTERVAL = 64
24
- const AUTO_SAVE_INTERVAL = 300
25
- const SNAP_PLAYER_LOW = 4
26
- const SNAP_PLAYER_HIGH = 16
27
- const SNAP_RTT_LOW = 50
28
- const SNAP_RTT_HIGH = 200
29
- // Fraction of the per-tick time budget (1000/tickRate ms) that measured snapshot-build cost must exceed
30
- // to count as "expensive" -- mirrors the SNAP_RTT_LOW/HIGH pattern but on the real compute-cost axis.
31
- const SNAP_COST_LOW_FRAC = 0.15
32
- const SNAP_COST_HIGH_FRAC = 0.35
33
- // Below this many nearby players, tiering's sort+classify overhead isn't worth it -- every nearby
34
- // player already gets FULL state via the plain filterEncodedPlayersWithSelf path, same as before.
35
- const PLAYER_LOD_FULL_COUNT_THRESHOLD = 30
36
- // Per-client outgoing-bytes-per-tick cap for the SNAPSHOT payload's entities[] array. This bounds the
37
- // worst case (a player standing in a dense cluster of relevant dynamic entities, all within
38
- // PRIORITY_ENTITY_BUDGET's count cap but each carrying a large `custom` payload) instead of only
39
- // capping entity COUNT -- a count cap alone still lets total bytes balloon per-entity. Real UDP-class
40
- // unreliable transports fragment/drop above ~1200 safe-MTU bytes per datagram; 900 leaves headroom for
41
- // msgpack framing + the players[]/removed[]/seq/tick/serverTime wrapper fields already sharing the
42
- // same packet, and mirrors this file's own SNAP_UNRELIABLE assumption (this is the unreliable-channel
43
- // snapshot path, not a reliable stream where fragmentation is free). Never applied to players[] (always
44
- // sent in full -- they're the highest-priority, typically-small payload) or the static entities[] head
45
- // (already deduped/rare-changing, and dropping map geometry updates would desync collision-relevant
46
- // state); only trims the DYNAMIC tail, farthest-from-viewer first, reusing the same distance-priority
47
- // signal getPlayerPriorityIds already computes.
48
- const BANDWIDTH_BUDGET_BYTES_PER_TICK = 900
49
- // Below this entity count, the trim's own sort+repack overhead isn't worth paying -- a handful of
50
- // entities is already comfortably under budget in the overwhelming majority of real snapshots.
51
- const BANDWIDTH_TRIM_MIN_ENTITIES = 6
52
- // Hard cap on trim iterations so a pathological single-entity-over-budget payload (one enormous
53
- // `custom` blob) can't loop forever -- degrade gracefully to "as few entities as it takes, or give up
54
- // after this many drops" rather than an unbounded while loop.
55
- const BANDWIDTH_TRIM_MAX_ITERATIONS = 32
56
-
57
- let _lastYaw = NaN, _lastSinHalf = 0, _lastCosHalf = 1
58
-
59
- function processPlayerMovement(players, deps, tick, dt, playerIdleCounts, playerAccumDt) {
60
- const { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement, eventLog, transformRingWriter } = deps
61
- for (const player of players) {
62
- const inputs = playerManager.getInputs(player.id)
63
- const st = player.state
64
- // backlog <= INPUT_BACKLOG_DRAIN: apply latest immediately (steady state). Larger backlog: drain one/tick so a burst plays out smoothly instead of collapsing to last-only. Always ack the sequence actually applied.
65
- if (inputs.length > 0) {
66
- if (inputs.length <= INPUT_BACKLOG_DRAIN) {
67
- const last = inputs[inputs.length - 1]
68
- player.lastInput = last.data
69
- if (last.sequence != null) player.ackSequence = last.sequence
70
- playerManager.clearInputs(player.id)
71
- } else {
72
- const next = inputs.shift()
73
- player.lastInput = next.data
74
- if (next.sequence != null) player.ackSequence = next.sequence
75
- }
76
- }
77
- const inp = player.lastInput || null
78
- if (inp) {
79
- const yaw = inp.yaw || 0
80
- if (yaw !== _lastYaw) { const half = yaw / 2; _lastSinHalf = Math.sin(half); _lastCosHalf = Math.cos(half); _lastYaw = yaw }
81
- st.rotation[0] = 0; st.rotation[1] = _lastSinHalf; st.rotation[2] = 0; st.rotation[3] = _lastCosHalf
82
- st.crouch = inp.crouch ? 1 : 0; st.lookPitch = inp.pitch || 0; st.lookYaw = yaw
83
- // Compact viseme/emote expression code (animation-vrm-spring-bone-lod-expression-wire): a plain
84
- // u8 (see client/core/ExpressionCodes.js) piggybacked on PLAYER_INPUT, same flow as st.crouch from
85
- // inp.crouch just above -- stored server-side then rebroadcast via networkState.updatePlayer/
86
- // SnapshotEncoder.encodePlayer below so every OTHER client can apply it to this player's remote avatar.
87
- st.expr = inp.expr || 0
88
- }
89
- applyMovement(st, inp, movement, dt, playerManager.getMovementOverride?.(player.id) || null)
90
- if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch)
91
- const wishedVx = st.velocity[0], wishedVz = st.velocity[2]
92
- const hasInput = inp && (inp.forward || inp.backward || inp.left || inp.right || inp.jump)
93
- const isIdle = !hasInput && st.onGround && wishedVx * wishedVx + wishedVz * wishedVz < 1e-4
94
- const idleCount = playerIdleCounts.get(player.id) || 0
95
- if (isIdle && idleCount >= 1) { playerIdleCounts.set(player.id, idleCount + 1); playerAccumDt.delete(player.id) }
96
- else {
97
- const accumDt = Math.min(PHYSICS_MAX_ACCUM_DT, (playerAccumDt.get(player.id) || 0) + dt)
98
- // decimate physics only for idle players; an active/airborne player must step every tick or reconciliation reads as jumpy
99
- if (hasInput || inp?.jump || !st.onGround || (tick + player.id) % PHYSICS_PLAYER_DIVISOR === 0) {
100
- physicsIntegration.updatePlayerPhysics(player.id, st, accumDt); st.velocity[0] = wishedVx; st.velocity[2] = wishedVz; playerAccumDt.delete(player.id)
101
- } else { playerAccumDt.set(player.id, accumDt) }
102
- playerIdleCounts.set(player.id, isIdle ? idleCount + 1 : 0)
103
- }
104
- // Movement envelope check (anticheat-server-envelope-checks, docs/anticheat.md): a second,
105
- // independent layer beneath applyMovement's own structural speed caps -- see InputGuard.js's
106
- // enforceMovementEnvelope header comment for why this exists as defense-in-depth rather than
107
- // the primary mechanism. A legitimate player can never reach this branch; every real occurrence
108
- // is worth an operator-visible eventLog entry (non-blocking, mirrors the statistical-outlier
109
- // flags below -- flag, never auto-punish, since a false positive here would be a real player
110
- // losing speed for no visible reason).
111
- if (enforceMovementEnvelope(st, movement)) {
112
- eventLog?.record('anticheat_envelope_clamp', { playerId: player.id, position: [...st.position] }, { actor: player.id, reason: 'movement_envelope' })
113
- }
114
- lagCompensator.recordPlayerPosition(player.id, st.position, st.rotation, st.velocity, tick)
115
- // crouch wire slot is bit-packed (bit0=crouch, bit1=swimming) rather than a new protocol field --
116
- // every consumer of this value (anim locoState threshold, XR capsule-height check, collider shrink)
117
- // only ever tests it for truthiness, never `=== 1`, so packing a second flag into unused bits is a
118
- // zero-wire-shape-change addition. See SnapshotEncoder.js's encode/decode for the packed shape.
119
- const crouchFlags = (st.crouch ? 1 : 0) | (st.swimming ? 2 : 0)
120
- // Compact equipped-weapon code (animation-weapon-signal-clientside-wiring, see
121
- // src/shared/WeaponCodes.js): server-authoritative, set via AppRuntime.setPlayerWeapon (never from
122
- // client input, unlike st.expr/st.crouch above) -- just read through here into the snapshot wire,
123
- // same optional-numeric-field discipline as st.expr.
124
- networkState.updatePlayer(player.id, st.position, st.rotation, st.velocity, st.onGround, st.health, player.ackSequence ?? player.inputSequence, crouchFlags, st.lookPitch||0, st.lookYaw||0, st.expr||0, st.weapon||0)
125
- // SharedArrayBuffer transform-ring hot path (physics-dedicated-worker-transform-offload):
126
- // best-effort, non-authoritative -- the networkState.updatePlayer call above (feeding the existing
127
- // postMessage SNAPSHOT channel) remains the single source of truth for every consumer; this write
128
- // just ALSO publishes the same transform into shared memory for a consumer able to read it with
129
- // zero postMessage round-trip. transformRingWriter is undefined/null whenever the ring is
130
- // unavailable (see TransformRing.js's isRingAvailable) -- always guarded, never assumed present.
131
- if (transformRingWriter) transformRingWriter.write(player.id, st.position, st.rotation, st.velocity)
132
- }
133
- }
134
-
135
- // _cellCenterWorld: face-local plane coords (wx,wy, already tan-warped, i.e. ready to combine with
136
- // FACE_FRAME the same way planet-orchestrator.js's localToDeformed does) -> a real world-space point
137
- // on the ray through that face direction at the given radial distance. Mirrors CubeSphereCells.js's
138
- // FACE_FRAME table exactly (col0=U, col1=V, col2=center) so the reprojected point matches the same
139
- // face convention worldToCell used to resolve the cell in the first place.
140
- const _CELL_FACE_FRAME = [
141
- { c: [ 1, 0, 0], u: [0, 0, -1], v: [0, 1, 0] },
142
- { c: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] },
143
- { c: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1] },
144
- { c: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] },
145
- { c: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
146
- { c: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0] },
147
- ]
148
- function _cellCenterWorld(face, wx, wy, R, dist) {
149
- const F = _CELL_FACE_FRAME[face]
150
- const dx = wx * F.u[0] + wy * F.v[0] + R * F.c[0]
151
- const dy = wx * F.u[1] + wy * F.v[1] + R * F.c[1]
152
- const dz = wx * F.u[2] + wy * F.v[2] + R * F.c[2]
153
- const len = Math.hypot(dx, dy, dz) || 1
154
- return [(dx / len) * dist, (dy / len) * dist, (dz / len) * dist]
155
- }
156
-
157
- // computeRingRelevantIds: cube-sphere-cell-grid AOI, the real "ring of cells" subscription this
158
- // module implements. A single point radius-query (appRuntime.getRelevantDynamicIds/nearbyPlayerIds,
159
- // called once per unique cellKey by the caller) already returns every entity within relevanceRadius
160
- // of the CELL CENTER -- but relevanceRadius is also the cell's own edge length, so an entity sitting
161
- // just across a neighbor cell's border (still within a real player's relevanceRadius of THEM, since
162
- // players are not pinned to their cell center) can fall outside that single-cell query while still
163
- // being genuinely relevant to a player standing near the shared edge. The fix mirrors exactly how a
164
- // tile-based AOI system subscribes a viewer to its own cell PLUS its Moore neighborhood (a "ring"),
165
- // not just the one cell it happens to sit in: union the relevant-id query result across the cell and
166
- // its 8 neighbors (cross-face correct on the curved-space path via CubeSphereCells.neighborCells; a
167
- // flat 3x3 XZ union on the non-planet path), each neighbor's query still centered on that neighbor's
168
- // OWN cellViewerPos so every viewer sharing a given ring subscription computes the identical id set --
169
- // the same "shared decision, not shared position" invariant the single-cell path already established
170
- // for cellViewerPos-based distance tiering (see the tickMod comment below).
171
- function computeRingRelevantIds(cellKey, cellFace, cellCx, cellCy, cellsPerFace, planetRadius, relevanceRadius, appRuntime) {
172
- let ring = _ringCache.get(cellKey)
173
- if (ring) return ring
174
- const relSet = new Set(), nearSet = new Set()
175
- const addCell = (face, cx, cy, key) => {
176
- let c = _spatialCache.get(key)
177
- if (!c) {
178
- let cvp
179
- if (planetRadius > 0) {
180
- const ATAN_K = Math.PI / 4.0
181
- const foX = (cx + 0.5) * relevanceRadius - planetRadius
182
- const foY = (cy + 0.5) * relevanceRadius - planetRadius
183
- const wx = planetRadius * Math.tan((foX / planetRadius) * ATAN_K)
184
- const wy = planetRadius * Math.tan((foY / planetRadius) * ATAN_K)
185
- cvp = _cellCenterWorld(face, wx, wy, planetRadius, planetRadius)
186
- } else {
187
- cvp = [(cx + 0.5) * relevanceRadius, 0, (cy + 0.5) * relevanceRadius]
188
- }
189
- // Starvation guard keyed by the cell's own packed key: every player homed to this cell shares
190
- // the same starvation clock (matching the ring-of-cells "shared decision, not shared position"
191
- // invariant documented above), so a distant entity gets force-included for the whole cell's
192
- // viewers together, once, rather than each player independently re-discovering it.
193
- c = { nearbyPlayerIds: appRuntime.nearbyPlayerIdsHysteresis(cvp, relevanceRadius, key), relevantIds: appRuntime.getRelevantDynamicIdsWithStarvation(cvp, relevanceRadius, key), cellViewerPos: cvp }
194
- _spatialCache.set(key, c)
195
- }
196
- for (const id of c.relevantIds) relSet.add(id)
197
- for (const id of c.nearbyPlayerIds) nearSet.add(id)
198
- }
199
- if (planetRadius > 0) {
200
- const neighbors = neighborCells(cellFace, cellCx, cellCy, cellsPerFace)
201
- for (const n of neighbors) addCell(n.face, n.cx, n.cy, packCellKey(n.face, n.cx, n.cy, cellsPerFace))
202
- } else {
203
- for (let dx = -1; dx <= 1; dx++) {
204
- for (let dy = -1; dy <= 1; dy++) {
205
- if (dx === 0 && dy === 0) continue
206
- const ncx = cellCx + dx, ncy = cellCy + dy
207
- addCell(-1, ncx, ncy, (ncx * 65536 + ncy) | 0)
208
- }
209
- }
210
- }
211
- ring = { relevantIds: relSet, nearbyPlayerIds: nearSet }
212
- _ringCache.set(cellKey, ring)
213
- return ring
214
- }
215
-
216
- const _spatialCache = new Map()
217
- const _cellPackCache = new Map()
218
- // Ring (cell + 8-neighborhood) relevant-id union cache, cleared once per tick alongside _spatialCache.
219
- // Keyed by the SAME cellKey as _spatialCache -- one entry per unique home-cell any player sits in this
220
- // tick, not per player. See computeRingRelevantIds above.
221
- const _ringCache = new Map()
222
- // Player-LOD tiering scratch (see classifyPlayerTiers/filterEncodedPlayersTiered): rebuilt once per
223
- // buildAndSendSnapshots call (module-level to avoid a fresh Map allocation every tick, same pooling
224
- // pattern as _spatialCache/_cellPackCache above).
225
- const _playersByIdScratch = new Map()
226
- const _packWrapper = { type: MSG.SNAPSHOT, payload: null }
227
- const _priorityAccumulators = new Map()
228
- // module-scoped, cleared per-call to avoid GC churn (single-threaded tick, never re-entrant)
229
- const _priorityBuckets = [[], [], [], []]
230
- const _packPayload = { seq: 0, tick: 0, serverTime: 0, players: null, entities: null, removed: undefined, delta: 1, dots: undefined }
231
-
232
- export { PRIORITY_ENTITY_BUDGET, PRIORITY_DECAY, BANDWIDTH_BUDGET_BYTES_PER_TICK }
233
- const _priorityBin = {}
234
- export function getPlayerPriorityIds(playerId, relevantIds, dynCache, viewerPos, tick) {
235
- if (!_priorityAccumulators.has(playerId)) _priorityAccumulators.set(playerId, new Map())
236
- const acc = _priorityAccumulators.get(playerId)
237
- const vx = viewerPos[0], vy = viewerPos[1], vz = viewerPos[2]
238
-
239
- for (const id of relevantIds) {
240
- const entry = dynCache.get(id); if (!entry) continue
241
- // enc[2] is the packed 23-byte bin record (see SnapshotEncoder.js fillEntityEnc) -- unpack once
242
- // per scored entity per tick rather than reading stale flat numeric slots.
243
- unpackBinRecord(entry.enc[2], _priorityBin)
244
- const dx = _priorityBin.px-vx, dy = _priorityBin.py-vy, dz = _priorityBin.pz-vz
245
- const distSq = dx*dx+dy*dy+dz*dz
246
- const velSq = _priorityBin.vx*_priorityBin.vx+_priorityBin.vy*_priorityBin.vy+_priorityBin.vz*_priorityBin.vz
247
- const distScore = 1 / (1 + distSq * 0.001)
248
- const velScore = velSq >= 100 ? 1 : Math.sqrt(velSq) * 0.1
249
- const prev = acc.get(id) || 0
250
- acc.set(id, prev + distScore + velScore + PRIORITY_DECAY)
251
- }
252
-
253
- for (const id of acc.keys()) {
254
- if (!dynCache.has(id)) acc.delete(id)
255
- }
256
-
257
- if (acc.size <= PRIORITY_ENTITY_BUDGET) return relevantIds
258
-
259
- const buckets = _priorityBuckets
260
- buckets[0].length = 0; buckets[1].length = 0; buckets[2].length = 0; buckets[3].length = 0
261
- for (const [id, score] of acc) {
262
- if (score >= 3) buckets[0].push(id)
263
- else if (score >= 2) buckets[1].push(id)
264
- else if (score >= 1) buckets[2].push(id)
265
- else buckets[3].push(id)
266
- }
267
- const topIds = new Set()
268
- let remaining = PRIORITY_ENTITY_BUDGET
269
- for (const bucket of buckets) {
270
- for (const id of bucket) {
271
- if (remaining-- <= 0) break
272
- topIds.add(id)
273
- acc.set(id, 0)
274
- }
275
- if (remaining <= 0) break
276
- }
277
- return topIds
278
- }
279
-
280
- const _budgetBin = {}
281
- // Cheap per-record byte-size ESTIMATE (not a real msgpack measurement -- re-packing on every trim
282
- // iteration to get an exact byte count would cost more than the bandwidth it saves). A full entity
283
- // record is [id, model, 23-byte bin buffer, bodyType, custom, sleeping]; a delta record is
284
- // [id, mask, ...present fields]. id/mask/bodyType/sleeping are small msgpack-encoded ints/strings
285
- // (~1-3 bytes each); the bin buffer is a real, exact 23 bytes when present; custom is the one
286
- // unbounded field, estimated via JSON.stringify length (msgpack is typically slightly smaller than
287
- // JSON for the same object, so this errs conservative -- overestimating custom's cost trims a little
288
- // more eagerly than strictly necessary, never less, which is the safe direction for a budget).
289
- function estimateEntityBytes(enc) {
290
- let n = 8 // id + array/map framing overhead, flat estimate
291
- for (let i = 1; i < enc.length; i++) {
292
- const f = enc[i]
293
- if (f == null) continue
294
- if (f instanceof Uint8Array) n += f.byteLength
295
- else if (typeof f === 'string') n += f.length + 1
296
- else if (typeof f === 'number') n += 2
297
- else if (typeof f === 'object') { try { n += JSON.stringify(f).length } catch (_) { n += 16 } }
298
- else n += 1
299
- }
300
- return n
301
- }
302
-
303
- // Trims encoded.entities (in place, returns a new array) down toward BANDWIDTH_BUDGET_BYTES_PER_TICK,
304
- // dropping the FARTHEST-from-viewer dynamic entity first each iteration -- graceful degradation
305
- // (fewer/less-fresh far entities) rather than buffering or blocking the tick, avoiding the
306
- // bufferbloat/latency-spiral a client-side send queue would risk. staticCount entities at the front of
307
- // the array (see encodeDeltaFromCache: static entries are always pushed before any dynamic entry) are
308
- // never trimmed -- dropping map/collision-relevant static geometry updates would desync client-side
309
- // collision, a correctness cost far worse than a slightly stale distant prop. Returns { entities,
310
- // trimmedCount } so a caller can log/telemetry the degradation instead of it being silent.
311
- function trimEntitiesToBudget(entities, staticCount, viewerPos) {
312
- if (entities.length - staticCount < BANDWIDTH_TRIM_MIN_ENTITIES) return { entities, trimmedCount: 0 }
313
- let total = 0
314
- const sized = new Array(entities.length)
315
- for (let i = 0; i < entities.length; i++) { const b = estimateEntityBytes(entities[i]); sized[i] = b; total += b }
316
- if (total <= BANDWIDTH_BUDGET_BYTES_PER_TICK) return { entities, trimmedCount: 0 }
317
- const vx = viewerPos ? viewerPos[0] : 0, vy = viewerPos ? viewerPos[1] : 0, vz = viewerPos ? viewerPos[2] : 0
318
- // Candidate indices: dynamic entities only (index >= staticCount), each with its real squared
319
- // distance from the viewer where available (full records carry the 23-byte bin buffer at enc[2];
320
- // delta records only carry it when position/rot/vel/scale actually changed this tick -- a delta
321
- // missing it is scored as "far" (Infinity) so it trims before anything with a known-close position,
322
- // a deliberately conservative fallback since we can't cheaply know its real distance this tick).
323
- const candidates = []
324
- for (let i = staticCount; i < entities.length; i++) {
325
- const enc = entities[i]
326
- let d2 = Infinity
327
- const bin = (enc.length > 2 && enc[2] instanceof Uint8Array && enc[2].byteLength >= 12) ? enc[2] : null
328
- if (bin) {
329
- unpackBinRecord(bin, _budgetBin)
330
- const dx = _budgetBin.px - vx, dy = _budgetBin.py - vy, dz = _budgetBin.pz - vz
331
- d2 = dx * dx + dy * dy + dz * dz
332
- }
333
- candidates.push({ i, d2 })
334
- }
335
- candidates.sort((a, b) => b.d2 - a.d2) // farthest first
336
- const dropSet = new Set()
337
- let iterations = 0
338
- for (const c of candidates) {
339
- if (total <= BANDWIDTH_BUDGET_BYTES_PER_TICK) break
340
- if (++iterations > BANDWIDTH_TRIM_MAX_ITERATIONS) break
341
- dropSet.add(c.i)
342
- total -= sized[c.i]
343
- }
344
- if (dropSet.size === 0) return { entities, trimmedCount: 0 }
345
- const trimmed = entities.filter((_, i) => !dropSet.has(i))
346
- return { entities: trimmed, trimmedCount: dropSet.size }
347
- }
348
-
349
- export { trimEntitiesToBudget, estimateEntityBytes }
350
-
351
- function packSnapshot(seq, encoded) {
352
- _packPayload.seq = seq; _packPayload.tick = encoded.tick; _packPayload.serverTime = encoded.serverTime
353
- _packPayload.players = encoded.players; _packPayload.entities = encoded.entities
354
- _packPayload.removed = encoded.removed; _packPayload.delta = encoded.delta
355
- // dots: DOT-tier player-LOD crowd aggregate (see filterEncodedPlayersTiered) -- undefined on every
356
- // non-tiered snapshot (the common case), so msgpackr elides the key entirely, same as `removed`.
357
- _packPayload.dots = encoded.dots
358
- _packWrapper.payload = _packPayload
359
- const buf = pack(_packWrapper)
360
- // server-scale-prometheus-metrics-endpoint-dashboard: this is the single choke point every outgoing
361
- // snapshot payload passes through (shared-cell fast path, per-viewer delta path, and the legacy
362
- // relevanceRadius===0 broadcast path all call packSnapshot) -- the real SnapshotEncoder.js output length
363
- // the PRD row named, counted here rather than re-measured at each of the 3 call sites.
364
- recordSnapshotBytes(buf.length)
365
- return buf
366
- }
367
-
368
- function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) {
369
- const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps
370
- const playerSnap = networkState.getSnapshot()
371
- const playerCount = players.length
372
- const snapGroups = Math.max(1, Math.ceil(playerCount / 50))
373
- const curGroup = tick % snapGroups
374
- const activeStage = stageLoader ? stageLoader.getActiveStage() : null
375
- const relevanceRadius = activeStage ? activeStage.spatial.relevanceRadius : (getRelevanceRadius ? getRelevanceRadius() : 0)
376
- // planetRadius > 0 opts a world into curved-space cube-sphere cell addressing (see the cellKey
377
- // branch below); absent/0 keeps the flat Euclidean XZ grid, the correct default for a single
378
- // non-reanchoring tangent-plane world (PlanetFrame.js). Stage.js/StageLoader.js thread this
379
- // through from worldDef.planetRadius.
380
- const planetRadius = activeStage ? (activeStage.spatial.planetRadius || 0) : 0
381
-
382
- if (relevanceRadius > 0) {
383
- const curStaticVersion = appRuntime._staticVersion
384
- // sph-fluid-3d-client-render-verification: _staticVersion alone (spawn/destroy/body-type-change
385
- // only) is blind to a static-bodyType entity mutating its OWN entity.custom every tick (apps/
386
- // fluid-source, apps/fluid3d-source) -- getStaticCustomVersionSum() is a cheap O(staticCount)
387
- // integer-sum comparison (not the full O(staticCount) encode below) that catches that case too,
388
- // live-reproduced+fixed via a real browser-verb witness (see that function's own comment for detail).
389
- const curStaticCustomSum = appRuntime.getStaticCustomVersionSum ? appRuntime.getStaticCustomVersionSum() : 0
390
- let activeStaticEntries = null
391
- if (isKeyframe || curStaticVersion !== state.lastStaticVersion || curStaticCustomSum !== state.lastStaticCustomSum) {
392
- const staticSnap = appRuntime.getStaticSnapshot()
393
- const prevStaticMap = isKeyframe ? new Map() : state.staticEntityMap
394
- const { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, prevStaticMap)
395
- state.lastStaticEntries = staticEntries
396
- if (staticChanged || isKeyframe) { state.staticEntityMap = staticMap; state.staticEntityIds = SnapshotEncoder.buildStaticIds(staticMap); activeStaticEntries = isKeyframe ? staticEntries : changedEntries }
397
- state.lastStaticVersion = curStaticVersion
398
- state.lastStaticCustomSum = curStaticCustomSum
399
- }
400
- // BUGFIX (found live via this task's own removal-propagation witness): state.knownIds must NOT be
401
- // reset to null on every _staticVersion bump. _staticVersion increments on EVERY entity spawn/
402
- // destroy/body-type-change (AppRuntime.js), which is exactly the same tick a real removal needs its
403
- // tombstone recorded on. updateTombstones(..., prevKnownIds) is a no-op whenever prevKnownIds is
404
- // null (nothing to diff against yet), so nulling it here silently swallowed the tombstone for
405
- // whatever entity just disappeared on THIS tick, every single time -- a removal was only ever
406
- // caught if it happened to coincide with an UNRELATED already-in-flight known-id set from a prior
407
- // tick. dynCache/prevDynCache still gets a full, correct rebuild here (buildDynamicCache, unrelated
408
- // to this bug) -- only the known-id diff baseline must survive across a version bump so this tick's
409
- // real removal is compared against last tick's real known set. Reset ONLY on isKeyframe: a keyframe
410
- // tick ships every client a full snapshot (not a delta) and also clears playerLastTick, so any
411
- // client reading the tombstone log after a keyframe starts from clientLastTick=0 and replays full
412
- // history anyway -- losing one tick's worth of already-covered-by-the-keyframe diff there is safe.
413
- if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion }
414
- if (isKeyframe) { state.knownIds = null; state.playerLastTick.clear() }
415
- const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players)
416
- // Player-LOD tiering (see SnapshotEncoder.js classifyPlayerTiers/filterEncodedPlayersTiered):
417
- // built once per tick, shared across every viewer below -- a Map<id,player> lookup, not a
418
- // per-viewer rebuild. reducedTickMod derives the ~PLAYER_LOD_REDUCED_HZ on-wire rate for
419
- // REDUCED-tier players from this tick's actual (adaptive) snapshot cadence -- a player at the
420
- // current send rate of e.g. 20Hz gets reducedTickMod=4 so REDUCED updates land at ~5Hz.
421
- const playersById = _playersByIdScratch; playersById.clear()
422
- for (const p of playerSnap.players) playersById.set(p.id, p)
423
- const snapshotHz = deps.getSnapshotHz ? deps.getSnapshotHz() : 20
424
- const reducedTickMod = Math.max(1, Math.round(snapshotHz / PLAYER_LOD_REDUCED_HZ))
425
- _spatialCache.clear()
426
- _cellPackCache.clear()
427
- _ringCache.clear()
428
- let dynCache = null
429
- let unmanagedIds = null
430
- for (const player of players) {
431
- if (player.snapGroup % snapGroups !== curGroup) continue
432
- if (dynCache === null) {
433
- const activeIds = appRuntime.getActiveDynamicIds()
434
- unmanagedIds = appRuntime.getUnmanagedDynamicIds()
435
- if (state.prevDynCache === null) { state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime.getSleepingDynamicIds(), appRuntime.getSuspendedEntityIds(), appRuntime.entities, state.prevDynCache, unmanagedIds) }
436
- else { SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities, appRuntime.getSleepingDynamicIds(), appRuntime.getSuspendedEntityIds(), unmanagedIds) }
437
- dynCache = state.prevDynCache
438
- // Once per tick (not per client): diff this tick's known-id set (dynCache + static) against
439
- // last tick's to append exactly the entities that dropped out to the global tombstone log --
440
- // see updateTombstones/TombstoneLog in SnapshotEncoder.js. Each client below then diffs only
441
- // the tombstone slice newer than its own last-built tick, instead of re-scanning its full
442
- // prevEntityMap every tick.
443
- state.knownIds = updateTombstones(state.tombstoneLog, tick, dynCache, state.staticEntityIds, state.knownIds)
444
- }
445
- const isNewPlayer = !playerEntityMaps.has(player.id)
446
- const viewerPos = player.state.position
447
- // CURVED-SPACE CELL ADDRESSING (planetRadius configured on the active stage): the flat Euclidean
448
- // XZ cellKey below is exactly right for a single non-reanchoring tangent-plane world (the common
449
- // case -- see PlanetFrame.js), but breaks down once a world's relevanceRadius-sized interest
450
- // cells actually span cube-sphere face boundaries (a full-planet server, or a world large enough
451
- // that the tangent-plane's flatness error matters at cell-boundary scale): two players a few
452
- // meters apart straddling a face seam would hash to wildly different flat cellKeys despite being
453
- // spatially adjacent, defeating the whole point of interest-cell payload sharing at that seam.
454
- // worldToCell/packCellKey resolve the player's world position to its real cube-sphere face+cell
455
- // (cross-face-correct at edges and cube corners -- see CubeSphereCells.js), and cellViewerPos is
456
- // re-derived from that SAME face-local cell (not a flat XZ average), so the per-cell distance-tier
457
- // origin two seam-adjacent clients compute is geometrically consistent across the seam too.
458
- let cellKey, cellViewerPos, cellFace = -1, cellCx = 0, cellCy = 0, cellsPerFace = 0
459
- if (planetRadius > 0) {
460
- const c = worldToCell(viewerPos[0], viewerPos[1], viewerPos[2], planetRadius, relevanceRadius)
461
- cellFace = c.face; cellCx = c.cx; cellCy = c.cy
462
- cellsPerFace = Math.ceil((2 * planetRadius) / relevanceRadius)
463
- cellKey = packCellKey(cellFace, cellCx, cellCy, cellsPerFace)
464
- // cellViewerPos: reproject the face-local cell CENTER back out along the same ray direction the
465
- // player sits on, at the player's own radial distance -- gives a real world-space point near the
466
- // cell center on the curved surface (not a flat-plane average that would cut through the sphere).
467
- const ATAN_K = Math.PI / 4.0
468
- const foX = (cellCx + 0.5) * relevanceRadius - planetRadius
469
- const foY = (cellCy + 0.5) * relevanceRadius - planetRadius
470
- const wx = planetRadius * Math.tan((foX / planetRadius) * ATAN_K)
471
- const wy = planetRadius * Math.tan((foY / planetRadius) * ATAN_K)
472
- const dist = Math.hypot(viewerPos[0], viewerPos[1], viewerPos[2]) || planetRadius
473
- cellViewerPos = _cellCenterWorld(cellFace, wx, wy, planetRadius, dist)
474
- } else {
475
- const cx = Math.floor(viewerPos[0] / relevanceRadius), cz = Math.floor(viewerPos[2] / relevanceRadius)
476
- cellKey = (cx * 65536 + cz) | 0
477
- // cellViewerPos: the cell's own center point (not this player's exact position) -- used ONLY as
478
- // the distance-tier origin (proper-multi-tier-distance-lod-schedule-for-snapshot-updates), so
479
- // every client sharing a cell computes an IDENTICAL near/mid/far tier verdict per entity. This is
480
- // what makes per-viewer-encode-sharing-by-interest-cell sound: two clients in the same cell no
481
- // longer just share relevantIds/nearbyPlayerIds (pre-existing), they now also derive the same
482
- // tier decision, so their full entities[]/removed[] OUTPUT is identical whenever they also share
483
- // a delta baseline tick (see cellEncodeCache below) -- real payload sharing, not just id-set reuse.
484
- cellViewerPos = [(cx + 0.5) * relevanceRadius, viewerPos[1], (cz + 0.5) * relevanceRadius]
485
- }
486
- let cached = _spatialCache.get(cellKey)
487
- if (!cached) {
488
- cached = { nearbyPlayerIds: appRuntime.nearbyPlayerIds(viewerPos, relevanceRadius), relevantIds: appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius), cellViewerPos }
489
- _spatialCache.set(cellKey, cached)
490
- }
491
- // Player-LOD tiering: FULL state for the ~PLAYER_LOD_FULL_COUNT nearest players, position+yaw
492
- // at ~PLAYER_LOD_REDUCED_HZ for the next ring, and everything further aggregated into `dots`
493
- // (grid-bucketed counts, no per-player wire cost at all) -- see SnapshotEncoder.js. Falls back
494
- // to the un-tiered filterEncodedPlayersWithSelf ONLY when nearbyPlayerIds is small enough that
495
- // tiering can't help (avoids the sort+classify cost for the common few-player case).
496
- // reducedTickMod gates on snapshotSeq (increments by exactly 1 per buildAndSendSnapshots call),
497
- // NOT the raw physics `tick` counter -- `tick` advances by _snapshotInterval (often >1) between
498
- // calls here (buildAndSendSnapshots only runs on tick % _snapshotInterval === 0), so `tick %
499
- // reducedTickMod` would gate at the wrong cadence (reducedTickMod is derived from snapshot Hz,
500
- // meaningful only against a counter that increments once per snapshot).
501
- let preEncodedPlayers, playerDots
502
- if (cached.nearbyPlayerIds && cached.nearbyPlayerIds.length > PLAYER_LOD_FULL_COUNT_THRESHOLD) {
503
- const tiered = SnapshotEncoder.filterEncodedPlayersTiered(allEncodedPlayers, playersById, cached.nearbyPlayerIds, player.id, viewerPos, snapshotSeq, reducedTickMod)
504
- preEncodedPlayers = tiered.players; playerDots = tiered.dots.length ? tiered.dots : undefined
505
- } else {
506
- preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, cached.nearbyPlayerIds, player.id)
507
- }
508
- const scratch = deps.getPlayerScratch(player.id)
509
- const prevPlayerMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id)
510
- // Ring-of-cells subscription: union relevantIds/nearbyPlayerIds across the cell + its Moore
511
- // neighborhood (see computeRingRelevantIds) so an entity just across a neighbor cell's border is
512
- // never missed for a player standing near the shared edge -- a single-cell query alone only
513
- // guarantees coverage of relevanceRadius from the CELL CENTER, not from every point inside the
514
- // cell out to its own edges.
515
- const ring = computeRingRelevantIds(cellKey, cellFace, cellCx, cellCy, cellsPerFace, planetRadius, relevanceRadius, appRuntime)
516
- // Cube-sphere cell-grid AOI, shared per-cell encoded payload: when the ring's relevant-id count
517
- // fits inside the per-tick entity budget, EVERY player homed to this cell (not just a newly
518
- // joining one) shares ONE encode of entities[]/removed[] this tick, built once against a per-CELL
519
- // delta baseline (state.cellEntityMaps) rather than each player's own prevEntityMap, and diffed
520
- // for removals via a per-cell tombstone cursor (state.cellLastTick) instead of each player's own
521
- // last-tick. This is the real hot-path win: encode cost amortizes over every player sharing a
522
- // cell, not just id-set/nearbyPlayerIds reuse (which was the pre-existing partial win) and not
523
- // just brand-new joiners (the prior narrower special case). A cell whose ring exceeds the budget
524
- // (a dense/crowded region) falls back to the existing per-player priority-decayed path below --
525
- // sharing a budget-exceeding set would defeat the whole point of the budget (bounding worst-case
526
- // per-client payload size), so that fallback is a deliberate, honest limit, not an oversight.
527
- const useSharedCell = ring.relevantIds.size <= PRIORITY_ENTITY_BUDGET
528
- let encoded, entityMap
529
- if (useSharedCell) {
530
- let cellMap = state.cellEntityMaps.get(cellKey)
531
- if (!cellMap) { cellMap = new Map(); state.cellEntityMaps.set(cellKey, cellMap) }
532
- let shared = cached.sharedEncode
533
- if (!shared || shared.tick !== tick) {
534
- let relevantIds = ring.relevantIds
535
- if (unmanagedIds && unmanagedIds.length) {
536
- const relSet = relevantIds === ring.relevantIds ? new Set(relevantIds) : relevantIds
537
- for (const id of unmanagedIds) relSet.add(id)
538
- relevantIds = relSet
539
- }
540
- const cellLastTick = state.cellLastTick.get(cellKey) || 0
541
- // Static entries for the ONGOING per-cell delta stream are always the tick's true incremental
542
- // changed set (activeStaticEntries) -- never state.lastStaticEntries (a full re-send), and
543
- // never conditioned on which player happens to trigger the rebuild this tick (that would make
544
- // the shared payload's shape depend on iteration order, breaking the "one encode per cell"
545
- // invariant this whole path exists for). A freshly-joined player's need for the FULL static
546
- // set is handled separately below, from state.lastStaticEntries directly.
547
- const r = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, cellMap, [], activeStaticEntries, state.staticEntityMap, state.staticEntityIds, snapshotSeq, cached.cellViewerPos, null, state.tombstoneLog, cellLastTick, snapshotHz)
548
- shared = { tick, entities: r.encoded.entities, removed: r.encoded.removed, entityMap: r.entityMap }
549
- cached.sharedEncode = shared
550
- state.cellEntityMaps.set(cellKey, r.entityMap)
551
- state.cellLastTick.set(cellKey, tick)
552
- }
553
- // A player who was NOT already tracking this cell's baseline (just joined, or just crossed into
554
- // this cell from another) cannot safely receive a DELTA against the cell's ongoing baseline --
555
- // they never saw the earlier ticks that baseline's deltas assume as their starting state. Give
556
- // such a player the cell's FULL current entity set instead (cached.sharedFull, refreshed
557
- // alongside the shared delta every time it's rebuilt, itself also shared across every player
558
- // freshly joining the SAME cell this same tick) exactly once, then they ride the shared delta
559
- // stream from the next tick onward -- a real keyframe/delta-reset per (player,cell) transition,
560
- // the same correctness contract encodeDeltaFromCache's own prevEntityMap gives per-player today.
561
- const isFreshToCell = state.playerCell.get(player.id) !== cellKey
562
- entityMap = new Map(shared.entityMap)
563
- if (isFreshToCell) {
564
- let full = cached.sharedFull
565
- if (!full || full.tick !== tick) {
566
- const dynEntities = Array.from(shared.entityMap.values()).map(v => v[3]).filter(Boolean)
567
- const staticEnts = state.lastStaticEntries || []
568
- full = { tick, entities: staticEnts.map(se => se.enc).concat(dynEntities) }
569
- cached.sharedFull = full
570
- }
571
- encoded = { tick: playerSnap.tick || 0, serverTime: serverNow, players: preEncodedPlayers || [], entities: full.entities, removed: undefined, delta: 1 }
572
- } else {
573
- encoded = { tick: playerSnap.tick || 0, serverTime: serverNow, players: preEncodedPlayers || [], entities: shared.entities, removed: shared.removed, delta: 1 }
574
- }
575
- state.playerCell.set(player.id, cellKey)
576
- } else {
577
- let relevantIds = getPlayerPriorityIds(player.id, ring.relevantIds, dynCache, viewerPos, tick)
578
- // Unmanaged (physics-body-less) dynamic entities are ALWAYS forced relevant, independent of the
579
- // spatial octree's distance verdict -- Stage.syncPositions() keeps the octree in sync every tick
580
- // now, but this is a deliberate belt-and-suspenders guard: such an entity's octree entry could
581
- // still read stale for one tick around a relevance-radius boundary crossing (index update
582
- // happens before the relevance query in the same tick, but a future ordering change or a
583
- // skipped sync tick would silently reintroduce the freeze this bug was about). Cheap -- there
584
- // are typically very few physics-body-less dynamic entities in a world.
585
- if (unmanagedIds && unmanagedIds.length) {
586
- const relSet = relevantIds instanceof Set ? relevantIds : new Set(relevantIds)
587
- for (const id of unmanagedIds) relSet.add(id)
588
- relevantIds = relSet
589
- }
590
- const clientLastTick = isNewPlayer ? 0 : (state.playerLastTick.get(player.id) || 0)
591
- const staticEntriesForCall = isNewPlayer ? state.lastStaticEntries : activeStaticEntries
592
- const r = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, prevPlayerMap, preEncodedPlayers, staticEntriesForCall, state.staticEntityMap, state.staticEntityIds, snapshotSeq, viewerPos, scratch, state.tombstoneLog, clientLastTick, snapshotHz)
593
- encoded = r.encoded; entityMap = r.entityMap
594
- scratch.spareMap = prevPlayerMap
595
- state.playerCell.delete(player.id)
596
- // Per-client outgoing-bytes-per-tick budget: this is the one path with both a real per-viewer
597
- // entities[] array (not shared across players like the useSharedCell branch above, whose payload
598
- // must stay byte-identical for every viewer of the cell) and a known viewerPos to prioritize by
599
- // distance -- see trimEntitiesToBudget. Static entries always sit at the front of encoded.entities
600
- // (encodeDeltaFromCache pushes them before any dynamic entry) and are never trimmed.
601
- const staticCountForTrim = staticEntriesForCall ? staticEntriesForCall.length : 0
602
- if (encoded.entities.length - staticCountForTrim >= BANDWIDTH_TRIM_MIN_ENTITIES) {
603
- const trim = trimEntitiesToBudget(encoded.entities, staticCountForTrim, viewerPos)
604
- if (trim.trimmedCount > 0) encoded.entities = trim.entities
605
- }
606
- }
607
- // playerDots: DOT-tier crowd aggregate for this viewer (see filterEncodedPlayersTiered above).
608
- // Attached post-hoc rather than threaded through encodeDeltaFromCache's already-long positional
609
- // signature -- it is purely a function of (nearbyPlayerIds, viewerPos), independent of the
610
- // entity-delta machinery encodeDeltaFromCache owns. Bypasses the shared _cellPackCache below:
611
- // that cache assumes byte-identical packed output across every player sharing a cellKey this
612
- // tick, which playerDots (per-viewer, derived from each player's own distance to every nearby
613
- // player) breaks -- caching a dots-bearing pack under one cellKey would leak one viewer's dot
614
- // aggregate onto every other player sharing that cell's empty-entities fast path.
615
- if (playerDots) encoded.dots = playerDots
616
- state.playerLastTick.set(player.id, tick)
617
- playerEntityMaps.set(player.id, entityMap)
618
- if (encoded.entities.length === 0 && !encoded.removed && !playerDots) {
619
- let cellPack = _cellPackCache.get(cellKey)
620
- if (!cellPack) {
621
- cellPack = packSnapshot(snapshotSeq, encoded)
622
- _cellPackCache.set(cellKey, cellPack)
623
- }
624
- connections.sendPacked(player.id, cellPack, SNAP_UNRELIABLE, MSG.SNAPSHOT)
625
- } else {
626
- const packedData = packSnapshot(snapshotSeq, encoded)
627
- connections.sendPacked(player.id, packedData, SNAP_UNRELIABLE, MSG.SNAPSHOT)
628
- }
629
- }
630
- // Prune the tombstone log to the oldest tick any currently-connected client OR any live per-cell
631
- // baseline might still need -- bounds its memory to "removals since the slowest reader's last
632
- // snapshot" rather than growing forever. Cheap: runs once per tick, only when dynCache actually ran
633
- // this tick (dynCache !== null guards groups where no player in this tick's snapGroup triggered a
634
- // dynCache (re)build). Per-cell baselines are also pruned here: a cell nobody sits in anymore (no
635
- // player's playerCell entry references it) is dropped so cellEntityMaps/cellLastTick don't grow
636
- // unbounded as players roam across a large or planet-scale world.
637
- if (dynCache !== null && (state.playerLastTick.size > 0 || state.cellLastTick.size > 0)) {
638
- let minTick = tick
639
- for (const t of state.playerLastTick.values()) { if (t < minTick) minTick = t }
640
- for (const t of state.cellLastTick.values()) { if (t < minTick) minTick = t }
641
- state.tombstoneLog.pruneBefore(minTick)
642
- if (state.cellLastTick.size > 0) {
643
- const liveCells = new Set(state.playerCell.values())
644
- for (const key of state.cellLastTick.keys()) {
645
- if (!liveCells.has(key)) { state.cellLastTick.delete(key); state.cellEntityMaps.delete(key) }
646
- }
647
- }
648
- }
649
- } else {
650
- // No per-viewer relevanceRadius/AOI configured for this world -- every connected player is sent the
651
- // SAME encoded payload (one shared pack, `data` below), by design, with no per-viewer viewerPos to
652
- // prioritize a distance-based trim against. trimEntitiesToBudget is deliberately NOT applied on this
653
- // path for the same reason it's skipped on the useSharedCell per-cell path above: a byte-budget trim
654
- // is only meaningful (and safe -- never silently desyncing one viewer's state from another's) when it
655
- // can be computed per-viewer; this broadcast path's entire point is that every viewer gets an
656
- // identical payload. A relevanceRadius-configured world is the one this budgeter targets.
657
- const entitySnap = appRuntime.getSnapshot()
658
- const combined = { tick: playerSnap.tick, players: playerSnap.players, entities: entitySnap.entities, serverTime: serverNow }
659
- const prevMap = (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap
660
- const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
661
- state.broadcastEntityMap = entityMap
662
- const data = packSnapshot(snapshotSeq, encoded)
663
- for (const player of players) {
664
- if (!isKeyframe && player.snapGroup % snapGroups !== curGroup) continue
665
- connections.sendPacked(player.id, data, SNAP_UNRELIABLE, MSG.SNAPSHOT)
666
- }
667
- }
668
- }
669
-
670
- export function createTickHandler(deps) {
671
- // 60Hz default (was 128) -- mirrors src/sdk/server.js's config.tickRate||60; every real caller passes
672
- // tickRate explicitly, this is only a defensive fallback.
673
- const { networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement: m = {}, stageLoader, getRelevanceRadius, _movement, tickRate = 60, getWorldTimeOfDayConfig, getWorldWeatherConfig } = deps
674
- // Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync). Passed the LIVE
675
- // getWorldTimeOfDayConfig ACCESSOR (not a pre-resolved value) -- ServerTimeOfDay.js re-reads it lazily on
676
- // every tick, since ctx.currentWorldDef is NOT yet populated at TickHandler-construction time (see
677
- // ServerTimeOfDay.js's header comment for the real bug this fixes: a construction-time-only read always
678
- // saw worldDef===undefined and permanently disabled itself, live-witnessed with a real 2-client WS
679
- // harness against tps-game before this fix). Absent getWorldTimeOfDayConfig, or a config with
680
- // serverAuthoritative!==true, both leave this fully inert -- a caller that never passes it (or a world
681
- // without terrain.timeOfDay) sees zero behavior change.
682
- const serverTimeOfDay = createServerTimeOfDay(getWorldTimeOfDayConfig)
683
- // Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync). Same lazy-
684
- // accessor discipline as serverTimeOfDay immediately above (getWorldWeatherConfig re-read on every
685
- // isEnabled()/getSyncPayload() call, not resolved once at construction) for the identical reason: this
686
- // module is constructed before ctx.currentWorldDef is populated. Unlike serverTimeOfDay, ServerWeather
687
- // has no per-tick advance step -- it is a discrete state broadcast on CHANGE (see shouldBroadcast's
688
- // dirty flag), not a continuously-advancing clock re-broadcast on a fixed cadence.
689
- const serverWeather = createServerWeather(getWorldWeatherConfig)
690
- const KEYFRAME_INTERVAL = tickRate * 10
691
- let _snapshotInterval = 1
692
- let _snapRateAdjustTick = 0
693
- let _lastSnapRate = tickRate
694
- // opt-in: process.memoryUsage() + template string per keyframe log is real cost, gated off by default; SPOINT_TICK_PROFILE=1 or deps.enableProfiling enables
695
- const _PROFILE = deps.enableProfiling || (typeof process !== 'undefined' && process.env?.SPOINT_TICK_PROFILE === '1')
696
- const applyMovement = _movement?.applyMovement || _applyMovement
697
- const DEFAULT_MOVEMENT = _movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT
698
- const movement = { ...DEFAULT_MOVEMENT, ...m }
699
- const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement, eventLog: deps.eventLog, transformRingWriter: deps.transformRingWriter || null }
700
- // playerScratch: per-player pooled { entities:[], removed:[], spareMap:Map } reused every tick instead
701
- // of allocating fresh entities/removed arrays and a fresh nextMap per player per tick (128Hz x N
702
- // clients -- the dominant GC-pressure source this pools away). spareMap is the OTHER half of a
703
- // double-buffer with playerEntityMaps.get(id): each tick, encodeDeltaFromCache writes into spareMap
704
- // while reading the current playerEntityMaps entry as prevEntityMap, then the two are swapped -- so a
705
- // map is never cleared/reused while it is still this call's prevEntityMap (that would erase the very
706
- // data the delta is being computed against), and it only becomes the write target again once it has
707
- // aged out one full tick as the (now-stale, already-consumed) prevEntityMap.
708
- const playerScratch = new Map()
709
- function getPlayerScratch(id) {
710
- let s = playerScratch.get(id)
711
- if (!s) { s = { entities: [], removed: [], spareMap: new Map() }; playerScratch.set(id, s) }
712
- return s
713
- }
714
- // getSnapshotHz: a live accessor (not a captured value) so player-LOD REDUCED-tier throttling
715
- // (see buildAndSendSnapshots) always derives its ~5Hz on-wire cadence from the CURRENT adaptive
716
- // snapshot rate (_lastSnapRate, updated by _computeSnapshotInterval below as player count/RTT/cost
717
- // change), not a stale boot-time tickRate.
718
- const snapDeps = { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps: new Map(), playerScratch, getPlayerScratch, getSnapshotHz: () => _lastSnapRate }
719
- // cellEntityMaps/cellLastTick: the per-CELL delta baseline + tombstone cursor that makes shared
720
- // per-cell encoding real (see the useSharedCell branch in buildAndSendSnapshots) -- one Map/tick
721
- // number per unique AOI cell any player currently occupies, NOT per player. playerCell tracks which
722
- // cell each player's own last-received snapshot was baselined against, so a player who just joined a
723
- // cell (or crossed into it from another) is detected and given a one-time full resync instead of an
724
- // unsafe delta against baseline ticks they never saw.
725
- const snapState = { broadcastEntityMap: new Map(), staticEntityMap: new Map(), staticEntityIds: null, lastStaticEntries: null, lastStaticVersion: -1, lastStaticCustomSum: -1, lastDynVersion: -1, prevDynCache: null, tombstoneLog: new TombstoneLog(), knownIds: null, playerLastTick: new Map(), cellEntityMaps: new Map(), cellLastTick: new Map(), playerCell: new Map() }
726
- const playerIdleCounts = new Map(), playerAccumDt = new Map()
727
- const grid = new Map(), gridCells = new Map()
728
- let snapshotSeq = 0, profileLog = 0, profileSum = 0, profileSumSnap = 0, profileSumPhys = 0, profileSumMv = 0, profileCount = 0
729
- let _lastBudgetWarnMs = 0
730
-
731
- let _lastBandHz = tickRate
732
- let _rateChangeTick = 0
733
- // Real measured per-tick snapshot-build cost (EMA), fed from buildAndSendSnapshots' own wall time on
734
- // every tick a snapshot actually sends -- see _snapCostEmaMs update in onTick below. Player COUNT alone
735
- // is a proxy for "how expensive is this tick's snapshot work" that silently diverges from the real
736
- // driver: buildAndSendSnapshots' cost scales with relevance-filtered nearby-player/entity PAIRS within
737
- // each viewer's radius, not raw connected-player count -- a small dense crowd (everyone clustered,
738
- // mutually relevant) can cost far more per tick than a larger but spread-out population where most
739
- // players fall outside each other's relevanceRadius and get filtered out cheaply. SNAP_COST_HIGH_FRAC/
740
- // SNAP_COST_LOW_FRAC mirror the existing avgRtt high/low thresholds' shape (a real-measurement throttle
741
- // layered on top of the player-count band, not a replacement -- the band still provides a safe
742
- // cold-start default before any snapshot has been measured).
743
- let _snapCostEmaMs = 0
744
- const SNAP_COST_EMA_ALPHA = 0.2
745
-
746
- function _computeSnapshotInterval(players, tick) {
747
- const pc = players.length
748
- let bandHz = tickRate
749
- if (pc === 0) {
750
- bandHz = SNAP_RATE_IDLE_HZ
751
- } else if (pc <= SNAP_PLAYER_LOW) {
752
- bandHz = SNAP_RATE_MAX_HZ
753
- } else if (pc >= SNAP_PLAYER_HIGH) {
754
- bandHz = SNAP_RATE_MIN_HZ
755
- } else {
756
- const t = (pc - SNAP_PLAYER_LOW) / (SNAP_PLAYER_HIGH - SNAP_PLAYER_LOW)
757
- bandHz = Math.round(SNAP_RATE_MAX_HZ - t * (SNAP_RATE_MAX_HZ - SNAP_RATE_MIN_HZ))
758
- }
759
- const rateDiff = bandHz - _lastBandHz
760
- const tickSinceChange = tick - _rateChangeTick
761
- // Hysteresis applies ONLY to the player-count BAND (damps flapping as players join/leave near a band
762
- // edge) -- it must never gate whether RTT/real-cost gets RE-EVALUATED, or a population that settles
763
- // into a stable band (the common case) permanently freezes the RTT/cost throttles at whatever they
764
- // read the one time the band last changed. (Found live: a population stable at pc=3 for its whole
765
- // session never re-read avgRtt after the initial band settle, even after RTT spiked to 300ms well
766
- // past SNAP_RTT_HIGH=200 -- the rate stayed pinned at the pre-spike value forever.) So bandHz is
767
- // damped here, but avgRtt/_snapCostEmaMs are read and applied fresh on EVERY call.
768
- const targetHzBase = (Math.abs(rateDiff) <= 2 || tickSinceChange < tickRate * 2) ? _lastBandHz : bandHz
769
- if (targetHzBase !== _lastBandHz) { _lastBandHz = targetHzBase; _rateChangeTick = tick }
770
- let targetHz = targetHzBase
771
- let avgRtt = 0
772
- try {
773
- const conns = connections?.clients
774
- if (conns && conns.size > 0) {
775
- let rttSum = 0, rttCount = 0
776
- for (const client of conns.values()) {
777
- if (client.rtt != null) { rttSum += client.rtt; rttCount++ }
778
- }
779
- if (rttCount > 0) avgRtt = rttSum / rttCount
780
- }
781
- } catch (_) {}
782
- if (avgRtt > SNAP_RTT_HIGH) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
783
- else if (avgRtt > SNAP_RTT_LOW) targetHz = Math.round(targetHz * 0.75)
784
- if (avgRtt < SNAP_RTT_LOW && targetHz < SNAP_RATE_MAX_HZ) targetHz = Math.min(SNAP_RATE_MAX_HZ, targetHz + 2)
785
- // Real-cost throttle: a dense/clustered crowd measured expensive to snapshot (regardless of what the
786
- // player-count band alone would pick) pulls the rate down further, same direction+shape as the RTT
787
- // adjustment above but driven by actual measured compute, not an assumed-uniform per-player cost.
788
- const tickBudgetMs = 1000 / tickRate
789
- if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_HIGH_FRAC) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
790
- else if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_LOW_FRAC) targetHz = Math.round(targetHz * 0.75)
791
- return Math.max(1, Math.round(tickRate / Math.max(SNAP_RATE_IDLE_HZ, Math.min(SNAP_RATE_MAX_HZ, targetHz))))
792
- }
793
-
794
- // simulateTick: the PURE deterministic-simulation subset of a tick -- movement -> player collisions ->
795
- // physics.step -> appRuntime.tick -- with ZERO network I/O side effects (no snapshot build, no
796
- // connections.broadcast/emit, no networkState.setTick/rate-adjust bookkeeping). This is exactly the
797
- // slice rollback-tickhandler-resimulate-loop's rewind+replay-forward orchestration (RollbackLoop.js)
798
- // needs to call once per resimulated tick: onTick's snapshot/broadcast half is a real one-time-only
799
- // wire side effect (it would double-send stale snapshots for every already-broadcast historical tick
800
- // if replayed) and must never re-run, but the physics/app simulation half is exactly what a correct
801
- // GGPO-style resimulate pass re-executes with corrected input. Returns nothing; mutates players/physics/
802
- // appRuntime state in place, identically to what onTick's own inline sequence below does -- onTick
803
- // calls this function rather than duplicating the sequence, so the two can never drift apart.
804
- function simulateTick(tick, dt, players) {
805
- processPlayerMovement(players, mvDeps, tick, dt, playerIdleCounts, playerAccumDt)
806
- const cellSz = physicsIntegration.config.capsuleRadius * 8, minDist = physicsIntegration.config.capsuleRadius * 2
807
- applyPlayerCollisions(players, grid, gridCells, cellSz, minDist * minDist, minDist, dt, physicsIntegration)
808
- // must run before physics.step: drains VegPhysics/RockPhysics streamer-queued collider add/remove into Jolt's broadphase
809
- if (typeof physics.drainBodyQueue === 'function') physics.drainBodyQueue()
810
- physics.step(dt)
811
- appRuntime.tick(tick, dt)
812
- }
813
-
814
- function onTick(tick, dt) {
815
- const t0 = performance.now()
816
- const serverNow = Date.now()
817
- networkState.setTick(tick, serverNow)
818
- const players = playerManager.getConnectedPlayers()
819
-
820
- if (tick - _snapRateAdjustTick >= SNAP_RATE_ADJUST_INTERVAL) {
821
- _snapRateAdjustTick = tick
822
- _snapshotInterval = _computeSnapshotInterval(players, tick)
823
- if (players.length > 0 && connections) {
824
- _lastSnapRate = Math.round(tickRate / _snapshotInterval)
825
- connections.emit('snapshot-rate', { rate: _lastSnapRate, tick, interval: _snapshotInterval })
826
- }
827
- }
828
-
829
- const t1pre = performance.now()
830
- simulateTick(tick, dt, players)
831
- const t4 = performance.now()
832
- // sub-phase split points (mv/col/phys) are no longer individually measurable now that simulateTick is
833
- // one opaque call shared with the rollback resimulate path (simulateTick must stay a single indivisible
834
- // unit so the resimulate loop replays EXACTLY what onTick would have run, never a hand-picked subset of
835
- // its internal phases) -- t1/t2 collapse to t1pre and t3 to t4 so the profiler's mv/col/phys buckets
836
- // report the combined simulateTick total under `phys` rather than silently reporting a fabricated
837
- // (always-zero) split; sync/respawn/etc's OWN sub-timers (appRuntime._lastSyncMs etc, logged separately
838
- // below) still carry the fine-grained post-simulateTick detail.
839
- const t1 = t1pre, t2 = t1pre, t3 = t4
840
- if (players.length > 0 && tick % _snapshotInterval === 0) {
841
- snapshotSeq++
842
- buildAndSendSnapshots(players, appRuntime, snapDeps, tick, snapshotSeq, snapshotSeq % KEYFRAME_INTERVAL === 0, snapState, serverNow)
843
- // EMA of the REAL measured snapshot-build wall time, isolated to just this call (not the cleanup
844
- // loop/auto-save below) -- feeds _computeSnapshotInterval's real-cost throttle so a dense/clustered
845
- // crowd that's expensive to snapshot self-corrects even when raw connected-player count is low.
846
- const _snapCostMs = performance.now() - t4
847
- _snapCostEmaMs = _snapCostEmaMs === 0 ? _snapCostMs : (_snapCostEmaMs * (1 - SNAP_COST_EMA_ALPHA) + _snapCostMs * SNAP_COST_EMA_ALPHA)
848
- }
849
- // ~1Hz broadcast of every connected client's server-measured RTT (the same EWMA client.rtt already
850
- // computed per-HEARTBEAT in ServerHandlers.js, reused here rather than re-measuring). This is the data
851
- // a P2P/wireweave room's host-migration election (client/HostMigration.js) needs: in a star topology
852
- // (only the host has an RTC data channel to each joiner) there is no peer-to-peer ping mesh, so every
853
- // joiner learning the SAME server-observed RTT numbers is the only way they can all independently agree
854
- // on the same "lowest-ping remaining peer" winner without a vote round-trip. Harmless on the plain WS
855
- // server path too (clients that never look at PEER_RTT_TABLE simply ignore it) -- kept unconditional
856
- // rather than gated on a P2P flag so a WS-hosted room could reuse the same election code path later.
857
- if (players.length > 0 && tick % tickRate === 0) {
858
- const rttTable = {}, pubkeys = {}
859
- for (const p of players) {
860
- const c = connections.getClient(p.id)
861
- if (!c) continue
862
- if (c.rtt != null) rttTable[p.id] = c.rtt
863
- // Only populated for wireweave P2P peers (see ConnectionManager.addClient) -- lets every joiner
864
- // resolve a server playerId from this table back to the wireweave pubkey it needs to reconnect a
865
- // data channel to during host migration (client/HostMigration.js). Absent/empty on the plain WS path.
866
- if (c.peerPubkey) pubkeys[p.id] = c.peerPubkey
867
- }
868
- connections.broadcast(MSG.PEER_RTT_TABLE, { rtt: rttTable, pubkeys })
869
- }
870
- // Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync): advance every
871
- // tick (real elapsed dt, matching TimeOfDay.js's own local update() formula) so the fraction stays
872
- // correct regardless of snapshot/broadcast cadence, but only BROADCAST the coarse correction on
873
- // serverTimeOfDay's own ~5s real-time cadence (see ServerTimeOfDay.js's shouldBroadcast). Both calls
874
- // are no-ops when the world never opted in (worldDef.terrain.timeOfDay.serverAuthoritative!==true).
875
- serverTimeOfDay.tick(dt)
876
- if (players.length > 0 && serverTimeOfDay.shouldBroadcast()) {
877
- connections.broadcast(MSG.TIME_OF_DAY_SYNC, serverTimeOfDay.getSyncPayload())
878
- }
879
- // Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync): no per-tick
880
- // advance (unlike serverTimeOfDay above) -- shouldBroadcast only returns true once per real state
881
- // CHANGE (first activation, or a future setState() call from an admin/game-mode toggle), so this is a
882
- // cheap dirty-flag check every tick, not a real broadcast most ticks. No-op when the world never
883
- // opted in (worldDef.terrain.weather.serverAuthoritative!==true).
884
- if (players.length > 0 && serverWeather.shouldBroadcast()) {
885
- connections.broadcast(MSG.WEATHER_SYNC, serverWeather.getSyncPayload())
886
- }
887
- if (tick % (tickRate * AUTO_SAVE_INTERVAL) === 0 && tick > 0) {
888
- try { deps.onAutoSave?.() } catch (_) {}
889
- }
890
- for (const id of snapDeps.playerEntityMaps.keys()) { if (!playerManager.getPlayer(id)) { snapDeps.playerEntityMaps.delete(id); playerIdleCounts.delete(id); playerAccumDt.delete(id); _priorityAccumulators.delete(id); playerScratch.delete(id); snapState.playerLastTick.delete(id); snapState.playerCell.delete(id) } }
891
- const t5 = performance.now()
892
- try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) }
893
- if (players.length > 0) {
894
- profileSum += t5-t0; profileSumSnap += t5-t4; profileSumPhys += t3-t2; profileSumMv += t1-t0; profileCount++
895
- // server-scale-prometheus-metrics-endpoint-dashboard: same real per-phase durations the existing
896
- // profileSum* accumulators/console.log(_PROFILE) already compute, additionally fed into the
897
- // Prometheus histogram registry so a scrape sees the full distribution, not just a periodic log line.
898
- recordTickPhase('total', t5-t0); recordTickPhase('mv', t1-t0); recordTickPhase('phys', t3-t2); recordTickPhase('snap', t5-t4)
899
- }
900
- // rate-limited overrun warning: silent tick overrun is what causes pacing to fall behind under load with no visibility
901
- const tickBudgetMs = 1000 / tickRate
902
- if (t5 - t0 > tickBudgetMs * 2 && serverNow - _lastBudgetWarnMs > 1000) {
903
- _lastBudgetWarnMs = serverNow
904
- console.warn(`[TickHandler] tick ${tick} overran budget: ${(t5-t0).toFixed(2)}ms > ${(tickBudgetMs*2).toFixed(2)}ms (budget ${tickBudgetMs.toFixed(2)}ms) players:${players.length}`)
905
- }
906
- if (_PROFILE && ++profileLog % KEYFRAME_INTERVAL === 0) {
907
- const total=t5-t0, mem=typeof process!=='undefined'?process.memoryUsage():{heapUsed:0,rss:0,external:0,arrayBuffers:0}, avg=n => profileCount>0?(n/profileCount).toFixed(2):'0'
908
- const mb=n=>(n/1048576).toFixed(1)
909
- const dynIds=appRuntime._dynamicEntityIds?.size||0, activeDyn=appRuntime.getActiveDynamicIds()?.size||0
910
- const avgTotal=avg(profileSum),avgSnap=avg(profileSumSnap),avgPhys=avg(profileSumPhys),avgMv=avg(profileSumMv)
911
- profileSum=0; profileSumSnap=0; profileSumPhys=0; profileSumMv=0; profileCount=0
912
- let idleSkipped = 0; if (players.length > 0) for (const c of playerIdleCounts.values()) if (c >= 2) idleSkipped++
913
- const physSkipped = players.length > 0 ? playerAccumDt.size : 0
914
- try { console.log(`[tick-profile] tick:${tick} players:${players.length} idle:${idleSkipped} physSkip:${physSkipped} entities:${appRuntime.entities.size} dynIds:${dynIds} activeDyn:${activeDyn} total:${total.toFixed(2)}ms(avg:${avgTotal}) | mv:${(t1-t0).toFixed(2)}(avg:${avgMv}) col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)}(avg:${avgPhys}) app:${(t4-t3).toFixed(2)} sync:${(appRuntime._lastSyncMs||0).toFixed(2)} respawn:${(appRuntime._lastRespawnMs||0).toFixed(2)} spatial:${(appRuntime._lastSpatialMs||0).toFixed(2)} col2:${(appRuntime._lastCollisionMs||0).toFixed(2)} int:${(appRuntime._lastInteractMs||0).toFixed(2)} snap:${(t5-t4).toFixed(2)}(avg:${avgSnap}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`) } catch (_) {}
915
- }
916
- }
917
-
918
- // Attached (not just closed-over) so a late-joining player's connect handler -- ServerHandlers.js's
919
- // onClientConnect, which runs OUTSIDE this closure -- can read the CURRENT fraction for a one-time
920
- // join-time send, mirroring the existing ctx._terrainStreamer attach-after-create convention (see
921
- // WorkerEntry.js/ServerAPI.js). onTick itself is unused as a namespace by any caller today (setTickHandler
922
- // only ever calls it as a plain function), so this adds a read surface without touching that contract.
923
- onTick.serverTimeOfDay = serverTimeOfDay
924
- // server-scale-prometheus-metrics-endpoint-dashboard: a live read of the SAME profileSum*/profileCount
925
- // accumulators the existing _PROFILE console.log path already computes unconditionally every tick (see
926
- // the profileSum block above -- computed regardless of _PROFILE, only the console.log itself is gated).
927
- // Deliberately does NOT reset the accumulators on read (unlike the console.log path, which resets every
928
- // KEYFRAME_INTERVAL ticks) -- a Prometheus scrape is pull-based and may poll at an arbitrary cadence
929
- // uncoordinated with KEYFRAME_INTERVAL, so resetting on read here would make one scraper's read starve
930
- // a concurrent scraper's window; ServerAPI.js's /metrics route instead reads this on every request and
931
- // reports the average over however many ticks have accumulated since the last natural profileLog reset.
932
- onTick.getMetrics = () => ({
933
- avgTotalMs: profileCount > 0 ? profileSum / profileCount : 0,
934
- avgMvMs: profileCount > 0 ? profileSumMv / profileCount : 0,
935
- avgPhysMs: profileCount > 0 ? profileSumPhys / profileCount : 0,
936
- avgSnapMs: profileCount > 0 ? profileSumSnap / profileCount : 0,
937
- sampleCount: profileCount,
938
- })
939
- // rollback-tickhandler-resimulate-loop: exposes the pure deterministic-simulation subset (see
940
- // simulateTick's own header comment) so RollbackLoop.js can replay ticks with corrected input without
941
- // re-triggering this handler's network-broadcast side effects. Same attach-after-create convention as
942
- // serverTimeOfDay/serverWeather below -- a plain function property on the returned onTick closure.
943
- onTick.simulateTick = simulateTick
944
- // rollback-tickhandler-resimulate-loop: playerIdleCounts/playerAccumDt (processPlayerMovement's physics-
945
- // decimation scheduling state, see the isIdle/accumDt block above) are tick-history-dependent hidden state
946
- // that is NOT part of PhysicsWorld.snapshotBodies/snapshotCharacters -- a real bug found+fixed while
947
- // building RollbackLoop.js's live witness: resimulating from a restored physics snapshot WITHOUT also
948
- // restoring these two maps to their tick-30 values reproduced a real 0.27m/1.6(m/s) divergence even when
949
- // replaying the IDENTICAL scripted input the original forward run used, because the maps still held their
950
- // post-tick-40 values from the original run (an idle player who had already accumulated 9 skipped ticks'
951
- // worth of decimation state by tick 40 does not skip-decimate the same way on a resim starting fresh from
952
- // tick 30). snapshotSimState/restoreSimState expose exactly these two Maps (cloned, never the live
953
- // reference) so a rollback caller saves/restores them in lockstep with the physics snapshot every tick.
954
- onTick.snapshotSimState = () => ({ playerIdleCounts: new Map(playerIdleCounts), playerAccumDt: new Map(playerAccumDt) })
955
- onTick.restoreSimState = (s) => {
956
- if (!s) return
957
- playerIdleCounts.clear(); for (const [k, v] of s.playerIdleCounts) playerIdleCounts.set(k, v)
958
- playerAccumDt.clear(); for (const [k, v] of s.playerAccumDt) playerAccumDt.set(k, v)
959
- }
960
- // Same attach-after-create convention as serverTimeOfDay above, for the identical reason: ServerHandlers.js's
961
- // onClientConnect (outside this closure) needs to read the CURRENT weather state for a one-time
962
- // join-time backfill send.
963
- onTick.serverWeather = serverWeather
964
- // lockstep-desync-wireweave-transport-and-tickhandler-wiring: the TickHandler-side half of wiring
965
- // DesyncDetector.js/LockstepChecksum.js into a real lockstep peer's tick loop, reusing simulateTick
966
- // exactly as RollbackLoop.js's resimulate path already does above (the pure deterministic-simulation
967
- // subset, zero network-broadcast side effects -- a lockstep peer's own tick loop, unlike this file's
968
- // own onTick, never calls buildAndSendSnapshots at all: a P2P mesh peer has no "clients to snapshot",
969
- // every peer IS a full simulation, see LockstepTickSystem.js's header comment for why this driver
970
- // exists as a wholly separate onTick(tick,dt) consumer from the server-authoritative one above).
971
- //
972
- // simulateTickWithChecksum(tick, dt, players): runs simulateTick unchanged, then -- only on ticks
973
- // detector.isChecksumTick(tick) selects (see DesyncDetector.js's own cadence-owning comment) --
974
- // computes this peer's own checksumBodies(tick, physics.snapshotBodies()) and reports it through
975
- // `desyncTransport.reportLocalChecksum`, which both broadcasts it to the mesh AND feeds the local
976
- // detector, matching submitLocalInput's identical local-write-goes-through-the-same-path discipline
977
- // in LockstepInputTransport.js. Returns the detector's resolution result ({status:'verified'|'desync',
978
- // ...}) on a checksum tick that JUST became fully resolved by this peer's OWN report (the common case
979
- // when this peer is the last of the roster to report), or null on every other tick -- a caller that
980
- // wants to observe every resolution (including ones resolved by a remote peer's LATER-arriving report)
981
- // should use detector.onVerified/onDesync instead, exactly as constructed; this return value is a
982
- // same-tick convenience for a caller that only cares about its own report's synchronous outcome.
983
- onTick.attachDesyncChecksum = (desyncTransport, checksumFn) => {
984
- const detector = desyncTransport.detector
985
- const physics_ = desyncTransport.physics
986
- const computeChecksum = checksumFn || ((t) => checksumBodies(t, physics_.snapshotBodies()))
987
- return function simulateTickWithChecksum(tick, dt, players) {
988
- simulateTick(tick, dt, players)
989
- if (!detector.isChecksumTick(tick)) return null
990
- const checksum = computeChecksum(tick)
991
- return desyncTransport.reportLocalChecksum(tick, checksum)
992
- }
993
- }
994
- return onTick
995
- }
1
+ import { MSG } from '../protocol/MessageTypes.js'
2
+ import { SnapshotEncoder, unpackBinRecord, TombstoneLog, updateTombstones, PLAYER_LOD_REDUCED_HZ } from '../netcode/SnapshotEncoder.js'
3
+ import { pack } from '../protocol/msgpack.js'
4
+ import { applyMovement as _applyMovement, DEFAULT_MOVEMENT as _DEFAULT_MOVEMENT } from '../shared/movement.js'
5
+ import { applyPlayerCollisions } from '../netcode/CollisionSystem.js'
6
+ import { worldToCell, packCellKey, neighborCells } from '../terrain/CubeSphereCells.js'
7
+ import { createServerTimeOfDay } from './ServerTimeOfDay.js'
8
+ import { createServerWeather } from './ServerWeather.js'
9
+ import { enforceMovementEnvelope } from '../netcode/InputGuard.js'
10
+ import { checksumBodies } from '../netcode/LockstepChecksum.js'
11
+ import { recordSnapshotBytes, recordTickPhase } from './Metrics.js'
12
+ import { PRIORITY_ENTITY_BUDGET, PRIORITY_DECAY, BANDWIDTH_BUDGET_BYTES_PER_TICK, trimEntitiesToBudget, estimateEntityBytes, computeRingRelevantIds, getPlayerPriorityIds, _spatialCache, _cellPackCache, _ringCache } from './TickHandlerAOI.js'
13
+ export { PRIORITY_ENTITY_BUDGET, PRIORITY_DECAY, BANDWIDTH_BUDGET_BYTES_PER_TICK, trimEntitiesToBudget, estimateEntityBytes, getPlayerPriorityIds } from './TickHandlerAOI.js'
14
+
15
+ const MAX_SENDS_PER_TICK = 25
16
+ const INPUT_BACKLOG_DRAIN = 2
17
+ const PHYSICS_PLAYER_DIVISOR = 3
18
+ const PHYSICS_MAX_ACCUM_DT = 1 / 20
19
+ const SNAP_UNRELIABLE = true
20
+ const SNAP_RATE_MIN_HZ = 8
21
+ const SNAP_RATE_MAX_HZ = 30
22
+ const SNAP_RATE_IDLE_HZ = 4
23
+ const SNAP_RATE_ADJUST_INTERVAL = 64
24
+ const AUTO_SAVE_INTERVAL = 300
25
+ const SNAP_PLAYER_LOW = 4
26
+ const SNAP_PLAYER_HIGH = 16
27
+ const SNAP_RTT_LOW = 50
28
+ const SNAP_RTT_HIGH = 200
29
+ // Fraction of the per-tick time budget (1000/tickRate ms) that measured snapshot-build cost must exceed
30
+ // to count as "expensive" -- mirrors the SNAP_RTT_LOW/HIGH pattern but on the real compute-cost axis.
31
+ const SNAP_COST_LOW_FRAC = 0.15
32
+ const SNAP_COST_HIGH_FRAC = 0.35
33
+ // Below this many nearby players, tiering's sort+classify overhead isn't worth it -- every nearby
34
+ // player already gets FULL state via the plain filterEncodedPlayersWithSelf path, same as before.
35
+ const PLAYER_LOD_FULL_COUNT_THRESHOLD = 30
36
+ // Per-client outgoing-bytes-per-tick cap for the SNAPSHOT payload's entities[] array. This bounds the
37
+ // worst case (a player standing in a dense cluster of relevant dynamic entities, all within
38
+ // PRIORITY_ENTITY_BUDGET's count cap but each carrying a large `custom` payload) instead of only
39
+ // capping entity COUNT -- a count cap alone still lets total bytes balloon per-entity. Real UDP-class
40
+ // unreliable transports fragment/drop above ~1200 safe-MTU bytes per datagram; 900 leaves headroom for
41
+ // msgpack framing + the players[]/removed[]/seq/tick/serverTime wrapper fields already sharing the
42
+ // same packet, and mirrors this file's own SNAP_UNRELIABLE assumption (this is the unreliable-channel
43
+ // snapshot path, not a reliable stream where fragmentation is free). Never applied to players[] (always
44
+ // sent in full -- they're the highest-priority, typically-small payload) or the static entities[] head
45
+ // (already deduped/rare-changing, and dropping map geometry updates would desync collision-relevant
46
+ // state); only trims the DYNAMIC tail, farthest-from-viewer first, reusing the same distance-priority
47
+ // signal getPlayerPriorityIds already computes.
48
+ // Below this entity count, the trim's own sort+repack overhead isn't worth paying -- a handful of
49
+ // entities is already comfortably under budget in the overwhelming majority of real snapshots.
50
+ const BANDWIDTH_TRIM_MIN_ENTITIES = 6
51
+ // Hard cap on trim iterations so a pathological single-entity-over-budget payload (one enormous
52
+ // `custom` blob) can't loop forever -- degrade gracefully to "as few entities as it takes, or give up
53
+ // after this many drops" rather than an unbounded while loop.
54
+ const BANDWIDTH_TRIM_MAX_ITERATIONS = 32
55
+
56
+ let _lastYaw = NaN, _lastSinHalf = 0, _lastCosHalf = 1
57
+
58
+ function processPlayerMovement(players, deps, tick, dt, playerIdleCounts, playerAccumDt) {
59
+ const { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement, eventLog, transformRingWriter } = deps
60
+ for (const player of players) {
61
+ const inputs = playerManager.getInputs(player.id)
62
+ const st = player.state
63
+ // backlog <= INPUT_BACKLOG_DRAIN: apply latest immediately (steady state). Larger backlog: drain one/tick so a burst plays out smoothly instead of collapsing to last-only. Always ack the sequence actually applied.
64
+ if (inputs.length > 0) {
65
+ if (inputs.length <= INPUT_BACKLOG_DRAIN) {
66
+ const last = inputs[inputs.length - 1]
67
+ player.lastInput = last.data
68
+ if (last.sequence != null) player.ackSequence = last.sequence
69
+ playerManager.clearInputs(player.id)
70
+ } else {
71
+ const next = inputs.shift()
72
+ player.lastInput = next.data
73
+ if (next.sequence != null) player.ackSequence = next.sequence
74
+ }
75
+ }
76
+ const inp = player.lastInput || null
77
+ if (inp) {
78
+ const yaw = inp.yaw || 0
79
+ if (yaw !== _lastYaw) { const half = yaw / 2; _lastSinHalf = Math.sin(half); _lastCosHalf = Math.cos(half); _lastYaw = yaw }
80
+ st.rotation[0] = 0; st.rotation[1] = _lastSinHalf; st.rotation[2] = 0; st.rotation[3] = _lastCosHalf
81
+ st.crouch = inp.crouch ? 1 : 0; st.lookPitch = inp.pitch || 0; st.lookYaw = yaw
82
+ // Compact viseme/emote expression code (animation-vrm-spring-bone-lod-expression-wire): a plain
83
+ // u8 (see client/core/ExpressionCodes.js) piggybacked on PLAYER_INPUT, same flow as st.crouch from
84
+ // inp.crouch just above -- stored server-side then rebroadcast via networkState.updatePlayer/
85
+ // SnapshotEncoder.encodePlayer below so every OTHER client can apply it to this player's remote avatar.
86
+ st.expr = inp.expr || 0
87
+ }
88
+ applyMovement(st, inp, movement, dt, playerManager.getMovementOverride?.(player.id) || null)
89
+ if (inp) physicsIntegration.setCrouch(player.id, !!inp.crouch)
90
+ const wishedVx = st.velocity[0], wishedVz = st.velocity[2]
91
+ const hasInput = inp && (inp.forward || inp.backward || inp.left || inp.right || inp.jump)
92
+ const isIdle = !hasInput && st.onGround && wishedVx * wishedVx + wishedVz * wishedVz < 1e-4
93
+ const idleCount = playerIdleCounts.get(player.id) || 0
94
+ if (isIdle && idleCount >= 1) { playerIdleCounts.set(player.id, idleCount + 1); playerAccumDt.delete(player.id) }
95
+ else {
96
+ const accumDt = Math.min(PHYSICS_MAX_ACCUM_DT, (playerAccumDt.get(player.id) || 0) + dt)
97
+ // decimate physics only for idle players; an active/airborne player must step every tick or reconciliation reads as jumpy
98
+ if (hasInput || inp?.jump || !st.onGround || (tick + player.id) % PHYSICS_PLAYER_DIVISOR === 0) {
99
+ physicsIntegration.updatePlayerPhysics(player.id, st, accumDt); st.velocity[0] = wishedVx; st.velocity[2] = wishedVz; playerAccumDt.delete(player.id)
100
+ } else { playerAccumDt.set(player.id, accumDt) }
101
+ playerIdleCounts.set(player.id, isIdle ? idleCount + 1 : 0)
102
+ }
103
+ // Movement envelope check (anticheat-server-envelope-checks, docs/anticheat.md): a second,
104
+ // independent layer beneath applyMovement's own structural speed caps -- see InputGuard.js's
105
+ // enforceMovementEnvelope header comment for why this exists as defense-in-depth rather than
106
+ // the primary mechanism. A legitimate player can never reach this branch; every real occurrence
107
+ // is worth an operator-visible eventLog entry (non-blocking, mirrors the statistical-outlier
108
+ // flags below -- flag, never auto-punish, since a false positive here would be a real player
109
+ // losing speed for no visible reason).
110
+ if (enforceMovementEnvelope(st, movement)) {
111
+ eventLog?.record('anticheat_envelope_clamp', { playerId: player.id, position: [...st.position] }, { actor: player.id, reason: 'movement_envelope' })
112
+ }
113
+ lagCompensator.recordPlayerPosition(player.id, st.position, st.rotation, st.velocity, tick)
114
+ // crouch wire slot is bit-packed (bit0=crouch, bit1=swimming) rather than a new protocol field --
115
+ // every consumer of this value (anim locoState threshold, XR capsule-height check, collider shrink)
116
+ // only ever tests it for truthiness, never `=== 1`, so packing a second flag into unused bits is a
117
+ // zero-wire-shape-change addition. See SnapshotEncoder.js's encode/decode for the packed shape.
118
+ const crouchFlags = (st.crouch ? 1 : 0) | (st.swimming ? 2 : 0)
119
+ // Compact equipped-weapon code (animation-weapon-signal-clientside-wiring, see
120
+ // src/shared/WeaponCodes.js): server-authoritative, set via AppRuntime.setPlayerWeapon (never from
121
+ // client input, unlike st.expr/st.crouch above) -- just read through here into the snapshot wire,
122
+ // same optional-numeric-field discipline as st.expr.
123
+ networkState.updatePlayer(player.id, st.position, st.rotation, st.velocity, st.onGround, st.health, player.ackSequence ?? player.inputSequence, crouchFlags, st.lookPitch||0, st.lookYaw||0, st.expr||0, st.weapon||0)
124
+ // SharedArrayBuffer transform-ring hot path (physics-dedicated-worker-transform-offload):
125
+ // best-effort, non-authoritative -- the networkState.updatePlayer call above (feeding the existing
126
+ // postMessage SNAPSHOT channel) remains the single source of truth for every consumer; this write
127
+ // just ALSO publishes the same transform into shared memory for a consumer able to read it with
128
+ // zero postMessage round-trip. transformRingWriter is undefined/null whenever the ring is
129
+ // unavailable (see TransformRing.js's isRingAvailable) -- always guarded, never assumed present.
130
+ if (transformRingWriter) transformRingWriter.write(player.id, st.position, st.rotation, st.velocity)
131
+ }
132
+ }
133
+
134
+
135
+ function packSnapshot(seq, encoded) {
136
+ _packPayload.seq = seq; _packPayload.tick = encoded.tick; _packPayload.serverTime = encoded.serverTime
137
+ _packPayload.players = encoded.players; _packPayload.entities = encoded.entities
138
+ _packPayload.removed = encoded.removed; _packPayload.delta = encoded.delta
139
+ // dots: DOT-tier player-LOD crowd aggregate (see filterEncodedPlayersTiered) -- undefined on every
140
+ // non-tiered snapshot (the common case), so msgpackr elides the key entirely, same as `removed`.
141
+ _packPayload.dots = encoded.dots
142
+ _packWrapper.payload = _packPayload
143
+ const buf = pack(_packWrapper)
144
+ // server-scale-prometheus-metrics-endpoint-dashboard: this is the single choke point every outgoing
145
+ // snapshot payload passes through (shared-cell fast path, per-viewer delta path, and the legacy
146
+ // relevanceRadius===0 broadcast path all call packSnapshot) -- the real SnapshotEncoder.js output length
147
+ // the PRD row named, counted here rather than re-measured at each of the 3 call sites.
148
+ recordSnapshotBytes(buf.length)
149
+ return buf
150
+ }
151
+
152
+ function buildAndSendSnapshots(players, appRuntime, deps, tick, snapshotSeq, isKeyframe, state, serverNow) {
153
+ const { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps } = deps
154
+ const playerSnap = networkState.getSnapshot()
155
+ const playerCount = players.length
156
+ const snapGroups = Math.max(1, Math.ceil(playerCount / 50))
157
+ const curGroup = tick % snapGroups
158
+ const activeStage = stageLoader ? stageLoader.getActiveStage() : null
159
+ const relevanceRadius = activeStage ? activeStage.spatial.relevanceRadius : (getRelevanceRadius ? getRelevanceRadius() : 0)
160
+ // planetRadius > 0 opts a world into curved-space cube-sphere cell addressing (see the cellKey
161
+ // branch below); absent/0 keeps the flat Euclidean XZ grid, the correct default for a single
162
+ // non-reanchoring tangent-plane world (PlanetFrame.js). Stage.js/StageLoader.js thread this
163
+ // through from worldDef.planetRadius.
164
+ const planetRadius = activeStage ? (activeStage.spatial.planetRadius || 0) : 0
165
+
166
+ if (relevanceRadius > 0) {
167
+ const curStaticVersion = appRuntime._staticVersion
168
+ // sph-fluid-3d-client-render-verification: _staticVersion alone (spawn/destroy/body-type-change
169
+ // only) is blind to a static-bodyType entity mutating its OWN entity.custom every tick (apps/
170
+ // fluid-source, apps/fluid3d-source) -- getStaticCustomVersionSum() is a cheap O(staticCount)
171
+ // integer-sum comparison (not the full O(staticCount) encode below) that catches that case too,
172
+ // live-reproduced+fixed via a real browser-verb witness (see that function's own comment for detail).
173
+ const curStaticCustomSum = appRuntime.getStaticCustomVersionSum ? appRuntime.getStaticCustomVersionSum() : 0
174
+ let activeStaticEntries = null
175
+ if (isKeyframe || curStaticVersion !== state.lastStaticVersion || curStaticCustomSum !== state.lastStaticCustomSum) {
176
+ const staticSnap = appRuntime.getStaticSnapshot()
177
+ const prevStaticMap = isKeyframe ? new Map() : state.staticEntityMap
178
+ const { staticEntries, changedEntries, staticMap, staticChanged } = SnapshotEncoder.encodeStaticEntities(staticSnap.entities, prevStaticMap)
179
+ state.lastStaticEntries = staticEntries
180
+ if (staticChanged || isKeyframe) { state.staticEntityMap = staticMap; state.staticEntityIds = SnapshotEncoder.buildStaticIds(staticMap); activeStaticEntries = isKeyframe ? staticEntries : changedEntries }
181
+ state.lastStaticVersion = curStaticVersion
182
+ state.lastStaticCustomSum = curStaticCustomSum
183
+ }
184
+ // BUGFIX (found live via this task's own removal-propagation witness): state.knownIds must NOT be
185
+ // reset to null on every _staticVersion bump. _staticVersion increments on EVERY entity spawn/
186
+ // destroy/body-type-change (AppRuntime.js), which is exactly the same tick a real removal needs its
187
+ // tombstone recorded on. updateTombstones(..., prevKnownIds) is a no-op whenever prevKnownIds is
188
+ // null (nothing to diff against yet), so nulling it here silently swallowed the tombstone for
189
+ // whatever entity just disappeared on THIS tick, every single time -- a removal was only ever
190
+ // caught if it happened to coincide with an UNRELATED already-in-flight known-id set from a prior
191
+ // tick. dynCache/prevDynCache still gets a full, correct rebuild here (buildDynamicCache, unrelated
192
+ // to this bug) -- only the known-id diff baseline must survive across a version bump so this tick's
193
+ // real removal is compared against last tick's real known set. Reset ONLY on isKeyframe: a keyframe
194
+ // tick ships every client a full snapshot (not a delta) and also clears playerLastTick, so any
195
+ // client reading the tombstone log after a keyframe starts from clientLastTick=0 and replays full
196
+ // history anyway -- losing one tick's worth of already-covered-by-the-keyframe diff there is safe.
197
+ if (isKeyframe || curStaticVersion !== state.lastDynVersion) { state.prevDynCache = null; state.lastDynVersion = curStaticVersion }
198
+ if (isKeyframe) { state.knownIds = null; state.playerLastTick.clear() }
199
+ const allEncodedPlayers = SnapshotEncoder.encodePlayersOnce(playerSnap.players)
200
+ // Player-LOD tiering (see SnapshotEncoder.js classifyPlayerTiers/filterEncodedPlayersTiered):
201
+ // built once per tick, shared across every viewer below -- a Map<id,player> lookup, not a
202
+ // per-viewer rebuild. reducedTickMod derives the ~PLAYER_LOD_REDUCED_HZ on-wire rate for
203
+ // REDUCED-tier players from this tick's actual (adaptive) snapshot cadence -- a player at the
204
+ // current send rate of e.g. 20Hz gets reducedTickMod=4 so REDUCED updates land at ~5Hz.
205
+ const playersById = _playersByIdScratch; playersById.clear()
206
+ for (const p of playerSnap.players) playersById.set(p.id, p)
207
+ const snapshotHz = deps.getSnapshotHz ? deps.getSnapshotHz() : 20
208
+ const reducedTickMod = Math.max(1, Math.round(snapshotHz / PLAYER_LOD_REDUCED_HZ))
209
+ _spatialCache.clear()
210
+ _cellPackCache.clear()
211
+ _ringCache.clear()
212
+ let dynCache = null
213
+ let unmanagedIds = null
214
+ for (const player of players) {
215
+ if (player.snapGroup % snapGroups !== curGroup) continue
216
+ if (dynCache === null) {
217
+ const activeIds = appRuntime.getActiveDynamicIds()
218
+ unmanagedIds = appRuntime.getUnmanagedDynamicIds()
219
+ if (state.prevDynCache === null) { state.prevDynCache = SnapshotEncoder.buildDynamicCache(activeIds, appRuntime.getSleepingDynamicIds(), appRuntime.getSuspendedEntityIds(), appRuntime.entities, state.prevDynCache, unmanagedIds) }
220
+ else { SnapshotEncoder.refreshDynamicCache(state.prevDynCache, activeIds, appRuntime.entities, appRuntime.getSleepingDynamicIds(), appRuntime.getSuspendedEntityIds(), unmanagedIds) }
221
+ dynCache = state.prevDynCache
222
+ // Once per tick (not per client): diff this tick's known-id set (dynCache + static) against
223
+ // last tick's to append exactly the entities that dropped out to the global tombstone log --
224
+ // see updateTombstones/TombstoneLog in SnapshotEncoder.js. Each client below then diffs only
225
+ // the tombstone slice newer than its own last-built tick, instead of re-scanning its full
226
+ // prevEntityMap every tick.
227
+ state.knownIds = updateTombstones(state.tombstoneLog, tick, dynCache, state.staticEntityIds, state.knownIds)
228
+ }
229
+ const isNewPlayer = !playerEntityMaps.has(player.id)
230
+ const viewerPos = player.state.position
231
+ // CURVED-SPACE CELL ADDRESSING (planetRadius configured on the active stage): the flat Euclidean
232
+ // XZ cellKey below is exactly right for a single non-reanchoring tangent-plane world (the common
233
+ // case -- see PlanetFrame.js), but breaks down once a world's relevanceRadius-sized interest
234
+ // cells actually span cube-sphere face boundaries (a full-planet server, or a world large enough
235
+ // that the tangent-plane's flatness error matters at cell-boundary scale): two players a few
236
+ // meters apart straddling a face seam would hash to wildly different flat cellKeys despite being
237
+ // spatially adjacent, defeating the whole point of interest-cell payload sharing at that seam.
238
+ // worldToCell/packCellKey resolve the player's world position to its real cube-sphere face+cell
239
+ // (cross-face-correct at edges and cube corners -- see CubeSphereCells.js), and cellViewerPos is
240
+ // re-derived from that SAME face-local cell (not a flat XZ average), so the per-cell distance-tier
241
+ // origin two seam-adjacent clients compute is geometrically consistent across the seam too.
242
+ let cellKey, cellViewerPos, cellFace = -1, cellCx = 0, cellCy = 0, cellsPerFace = 0
243
+ if (planetRadius > 0) {
244
+ const c = worldToCell(viewerPos[0], viewerPos[1], viewerPos[2], planetRadius, relevanceRadius)
245
+ cellFace = c.face; cellCx = c.cx; cellCy = c.cy
246
+ cellsPerFace = Math.ceil((2 * planetRadius) / relevanceRadius)
247
+ cellKey = packCellKey(cellFace, cellCx, cellCy, cellsPerFace)
248
+ // cellViewerPos: reproject the face-local cell CENTER back out along the same ray direction the
249
+ // player sits on, at the player's own radial distance -- gives a real world-space point near the
250
+ // cell center on the curved surface (not a flat-plane average that would cut through the sphere).
251
+ const ATAN_K = Math.PI / 4.0
252
+ const foX = (cellCx + 0.5) * relevanceRadius - planetRadius
253
+ const foY = (cellCy + 0.5) * relevanceRadius - planetRadius
254
+ const wx = planetRadius * Math.tan((foX / planetRadius) * ATAN_K)
255
+ const wy = planetRadius * Math.tan((foY / planetRadius) * ATAN_K)
256
+ const dist = Math.hypot(viewerPos[0], viewerPos[1], viewerPos[2]) || planetRadius
257
+ cellViewerPos = _cellCenterWorld(cellFace, wx, wy, planetRadius, dist)
258
+ } else {
259
+ const cx = Math.floor(viewerPos[0] / relevanceRadius), cz = Math.floor(viewerPos[2] / relevanceRadius)
260
+ cellKey = (cx * 65536 + cz) | 0
261
+ // cellViewerPos: the cell's own center point (not this player's exact position) -- used ONLY as
262
+ // the distance-tier origin (proper-multi-tier-distance-lod-schedule-for-snapshot-updates), so
263
+ // every client sharing a cell computes an IDENTICAL near/mid/far tier verdict per entity. This is
264
+ // what makes per-viewer-encode-sharing-by-interest-cell sound: two clients in the same cell no
265
+ // longer just share relevantIds/nearbyPlayerIds (pre-existing), they now also derive the same
266
+ // tier decision, so their full entities[]/removed[] OUTPUT is identical whenever they also share
267
+ // a delta baseline tick (see cellEncodeCache below) -- real payload sharing, not just id-set reuse.
268
+ cellViewerPos = [(cx + 0.5) * relevanceRadius, viewerPos[1], (cz + 0.5) * relevanceRadius]
269
+ }
270
+ let cached = _spatialCache.get(cellKey)
271
+ if (!cached) {
272
+ cached = { nearbyPlayerIds: appRuntime.nearbyPlayerIds(viewerPos, relevanceRadius), relevantIds: appRuntime.getRelevantDynamicIds(viewerPos, relevanceRadius), cellViewerPos }
273
+ _spatialCache.set(cellKey, cached)
274
+ }
275
+ // Player-LOD tiering: FULL state for the ~PLAYER_LOD_FULL_COUNT nearest players, position+yaw
276
+ // at ~PLAYER_LOD_REDUCED_HZ for the next ring, and everything further aggregated into `dots`
277
+ // (grid-bucketed counts, no per-player wire cost at all) -- see SnapshotEncoder.js. Falls back
278
+ // to the un-tiered filterEncodedPlayersWithSelf ONLY when nearbyPlayerIds is small enough that
279
+ // tiering can't help (avoids the sort+classify cost for the common few-player case).
280
+ // reducedTickMod gates on snapshotSeq (increments by exactly 1 per buildAndSendSnapshots call),
281
+ // NOT the raw physics `tick` counter -- `tick` advances by _snapshotInterval (often >1) between
282
+ // calls here (buildAndSendSnapshots only runs on tick % _snapshotInterval === 0), so `tick %
283
+ // reducedTickMod` would gate at the wrong cadence (reducedTickMod is derived from snapshot Hz,
284
+ // meaningful only against a counter that increments once per snapshot).
285
+ let preEncodedPlayers, playerDots
286
+ if (cached.nearbyPlayerIds && cached.nearbyPlayerIds.length > PLAYER_LOD_FULL_COUNT_THRESHOLD) {
287
+ const tiered = SnapshotEncoder.filterEncodedPlayersTiered(allEncodedPlayers, playersById, cached.nearbyPlayerIds, player.id, viewerPos, snapshotSeq, reducedTickMod)
288
+ preEncodedPlayers = tiered.players; playerDots = tiered.dots.length ? tiered.dots : undefined
289
+ } else {
290
+ preEncodedPlayers = SnapshotEncoder.filterEncodedPlayersWithSelf(allEncodedPlayers, cached.nearbyPlayerIds, player.id)
291
+ }
292
+ const scratch = deps.getPlayerScratch(player.id)
293
+ const prevPlayerMap = isNewPlayer ? new Map() : playerEntityMaps.get(player.id)
294
+ // Ring-of-cells subscription: union relevantIds/nearbyPlayerIds across the cell + its Moore
295
+ // neighborhood (see computeRingRelevantIds) so an entity just across a neighbor cell's border is
296
+ // never missed for a player standing near the shared edge -- a single-cell query alone only
297
+ // guarantees coverage of relevanceRadius from the CELL CENTER, not from every point inside the
298
+ // cell out to its own edges.
299
+ const ring = computeRingRelevantIds(cellKey, cellFace, cellCx, cellCy, cellsPerFace, planetRadius, relevanceRadius, appRuntime)
300
+ // Cube-sphere cell-grid AOI, shared per-cell encoded payload: when the ring's relevant-id count
301
+ // fits inside the per-tick entity budget, EVERY player homed to this cell (not just a newly
302
+ // joining one) shares ONE encode of entities[]/removed[] this tick, built once against a per-CELL
303
+ // delta baseline (state.cellEntityMaps) rather than each player's own prevEntityMap, and diffed
304
+ // for removals via a per-cell tombstone cursor (state.cellLastTick) instead of each player's own
305
+ // last-tick. This is the real hot-path win: encode cost amortizes over every player sharing a
306
+ // cell, not just id-set/nearbyPlayerIds reuse (which was the pre-existing partial win) and not
307
+ // just brand-new joiners (the prior narrower special case). A cell whose ring exceeds the budget
308
+ // (a dense/crowded region) falls back to the existing per-player priority-decayed path below --
309
+ // sharing a budget-exceeding set would defeat the whole point of the budget (bounding worst-case
310
+ // per-client payload size), so that fallback is a deliberate, honest limit, not an oversight.
311
+ const useSharedCell = ring.relevantIds.size <= PRIORITY_ENTITY_BUDGET
312
+ let encoded, entityMap
313
+ if (useSharedCell) {
314
+ let cellMap = state.cellEntityMaps.get(cellKey)
315
+ if (!cellMap) { cellMap = new Map(); state.cellEntityMaps.set(cellKey, cellMap) }
316
+ let shared = cached.sharedEncode
317
+ if (!shared || shared.tick !== tick) {
318
+ let relevantIds = ring.relevantIds
319
+ if (unmanagedIds && unmanagedIds.length) {
320
+ const relSet = relevantIds === ring.relevantIds ? new Set(relevantIds) : relevantIds
321
+ for (const id of unmanagedIds) relSet.add(id)
322
+ relevantIds = relSet
323
+ }
324
+ const cellLastTick = state.cellLastTick.get(cellKey) || 0
325
+ // Static entries for the ONGOING per-cell delta stream are always the tick's true incremental
326
+ // changed set (activeStaticEntries) -- never state.lastStaticEntries (a full re-send), and
327
+ // never conditioned on which player happens to trigger the rebuild this tick (that would make
328
+ // the shared payload's shape depend on iteration order, breaking the "one encode per cell"
329
+ // invariant this whole path exists for). A freshly-joined player's need for the FULL static
330
+ // set is handled separately below, from state.lastStaticEntries directly.
331
+ const r = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, cellMap, [], activeStaticEntries, state.staticEntityMap, state.staticEntityIds, snapshotSeq, cached.cellViewerPos, null, state.tombstoneLog, cellLastTick, snapshotHz)
332
+ shared = { tick, entities: r.encoded.entities, removed: r.encoded.removed, entityMap: r.entityMap }
333
+ cached.sharedEncode = shared
334
+ state.cellEntityMaps.set(cellKey, r.entityMap)
335
+ state.cellLastTick.set(cellKey, tick)
336
+ }
337
+ // A player who was NOT already tracking this cell's baseline (just joined, or just crossed into
338
+ // this cell from another) cannot safely receive a DELTA against the cell's ongoing baseline --
339
+ // they never saw the earlier ticks that baseline's deltas assume as their starting state. Give
340
+ // such a player the cell's FULL current entity set instead (cached.sharedFull, refreshed
341
+ // alongside the shared delta every time it's rebuilt, itself also shared across every player
342
+ // freshly joining the SAME cell this same tick) exactly once, then they ride the shared delta
343
+ // stream from the next tick onward -- a real keyframe/delta-reset per (player,cell) transition,
344
+ // the same correctness contract encodeDeltaFromCache's own prevEntityMap gives per-player today.
345
+ const isFreshToCell = state.playerCell.get(player.id) !== cellKey
346
+ entityMap = new Map(shared.entityMap)
347
+ if (isFreshToCell) {
348
+ let full = cached.sharedFull
349
+ if (!full || full.tick !== tick) {
350
+ const dynEntities = Array.from(shared.entityMap.values()).map(v => v[3]).filter(Boolean)
351
+ const staticEnts = state.lastStaticEntries || []
352
+ full = { tick, entities: staticEnts.map(se => se.enc).concat(dynEntities) }
353
+ cached.sharedFull = full
354
+ }
355
+ encoded = { tick: playerSnap.tick || 0, serverTime: serverNow, players: preEncodedPlayers || [], entities: full.entities, removed: undefined, delta: 1 }
356
+ } else {
357
+ encoded = { tick: playerSnap.tick || 0, serverTime: serverNow, players: preEncodedPlayers || [], entities: shared.entities, removed: shared.removed, delta: 1 }
358
+ }
359
+ state.playerCell.set(player.id, cellKey)
360
+ } else {
361
+ let relevantIds = getPlayerPriorityIds(player.id, ring.relevantIds, dynCache, viewerPos, tick)
362
+ // Unmanaged (physics-body-less) dynamic entities are ALWAYS forced relevant, independent of the
363
+ // spatial octree's distance verdict -- Stage.syncPositions() keeps the octree in sync every tick
364
+ // now, but this is a deliberate belt-and-suspenders guard: such an entity's octree entry could
365
+ // still read stale for one tick around a relevance-radius boundary crossing (index update
366
+ // happens before the relevance query in the same tick, but a future ordering change or a
367
+ // skipped sync tick would silently reintroduce the freeze this bug was about). Cheap -- there
368
+ // are typically very few physics-body-less dynamic entities in a world.
369
+ if (unmanagedIds && unmanagedIds.length) {
370
+ const relSet = relevantIds instanceof Set ? relevantIds : new Set(relevantIds)
371
+ for (const id of unmanagedIds) relSet.add(id)
372
+ relevantIds = relSet
373
+ }
374
+ const clientLastTick = isNewPlayer ? 0 : (state.playerLastTick.get(player.id) || 0)
375
+ const staticEntriesForCall = isNewPlayer ? state.lastStaticEntries : activeStaticEntries
376
+ const r = SnapshotEncoder.encodeDeltaFromCache(playerSnap.tick, serverNow, dynCache, relevantIds, prevPlayerMap, preEncodedPlayers, staticEntriesForCall, state.staticEntityMap, state.staticEntityIds, snapshotSeq, viewerPos, scratch, state.tombstoneLog, clientLastTick, snapshotHz)
377
+ encoded = r.encoded; entityMap = r.entityMap
378
+ scratch.spareMap = prevPlayerMap
379
+ state.playerCell.delete(player.id)
380
+ // Per-client outgoing-bytes-per-tick budget: this is the one path with both a real per-viewer
381
+ // entities[] array (not shared across players like the useSharedCell branch above, whose payload
382
+ // must stay byte-identical for every viewer of the cell) and a known viewerPos to prioritize by
383
+ // distance -- see trimEntitiesToBudget. Static entries always sit at the front of encoded.entities
384
+ // (encodeDeltaFromCache pushes them before any dynamic entry) and are never trimmed.
385
+ const staticCountForTrim = staticEntriesForCall ? staticEntriesForCall.length : 0
386
+ if (encoded.entities.length - staticCountForTrim >= BANDWIDTH_TRIM_MIN_ENTITIES) {
387
+ const trim = trimEntitiesToBudget(encoded.entities, staticCountForTrim, viewerPos)
388
+ if (trim.trimmedCount > 0) encoded.entities = trim.entities
389
+ }
390
+ }
391
+ // playerDots: DOT-tier crowd aggregate for this viewer (see filterEncodedPlayersTiered above).
392
+ // Attached post-hoc rather than threaded through encodeDeltaFromCache's already-long positional
393
+ // signature -- it is purely a function of (nearbyPlayerIds, viewerPos), independent of the
394
+ // entity-delta machinery encodeDeltaFromCache owns. Bypasses the shared _cellPackCache below:
395
+ // that cache assumes byte-identical packed output across every player sharing a cellKey this
396
+ // tick, which playerDots (per-viewer, derived from each player's own distance to every nearby
397
+ // player) breaks -- caching a dots-bearing pack under one cellKey would leak one viewer's dot
398
+ // aggregate onto every other player sharing that cell's empty-entities fast path.
399
+ if (playerDots) encoded.dots = playerDots
400
+ state.playerLastTick.set(player.id, tick)
401
+ playerEntityMaps.set(player.id, entityMap)
402
+ if (encoded.entities.length === 0 && !encoded.removed && !playerDots) {
403
+ let cellPack = _cellPackCache.get(cellKey)
404
+ if (!cellPack) {
405
+ cellPack = packSnapshot(snapshotSeq, encoded)
406
+ _cellPackCache.set(cellKey, cellPack)
407
+ }
408
+ connections.sendPacked(player.id, cellPack, SNAP_UNRELIABLE, MSG.SNAPSHOT)
409
+ } else {
410
+ const packedData = packSnapshot(snapshotSeq, encoded)
411
+ connections.sendPacked(player.id, packedData, SNAP_UNRELIABLE, MSG.SNAPSHOT)
412
+ }
413
+ }
414
+ // Prune the tombstone log to the oldest tick any currently-connected client OR any live per-cell
415
+ // baseline might still need -- bounds its memory to "removals since the slowest reader's last
416
+ // snapshot" rather than growing forever. Cheap: runs once per tick, only when dynCache actually ran
417
+ // this tick (dynCache !== null guards groups where no player in this tick's snapGroup triggered a
418
+ // dynCache (re)build). Per-cell baselines are also pruned here: a cell nobody sits in anymore (no
419
+ // player's playerCell entry references it) is dropped so cellEntityMaps/cellLastTick don't grow
420
+ // unbounded as players roam across a large or planet-scale world.
421
+ if (dynCache !== null && (state.playerLastTick.size > 0 || state.cellLastTick.size > 0)) {
422
+ let minTick = tick
423
+ for (const t of state.playerLastTick.values()) { if (t < minTick) minTick = t }
424
+ for (const t of state.cellLastTick.values()) { if (t < minTick) minTick = t }
425
+ state.tombstoneLog.pruneBefore(minTick)
426
+ if (state.cellLastTick.size > 0) {
427
+ const liveCells = new Set(state.playerCell.values())
428
+ for (const key of state.cellLastTick.keys()) {
429
+ if (!liveCells.has(key)) { state.cellLastTick.delete(key); state.cellEntityMaps.delete(key) }
430
+ }
431
+ }
432
+ }
433
+ } else {
434
+ // No per-viewer relevanceRadius/AOI configured for this world -- every connected player is sent the
435
+ // SAME encoded payload (one shared pack, `data` below), by design, with no per-viewer viewerPos to
436
+ // prioritize a distance-based trim against. trimEntitiesToBudget is deliberately NOT applied on this
437
+ // path for the same reason it's skipped on the useSharedCell per-cell path above: a byte-budget trim
438
+ // is only meaningful (and safe -- never silently desyncing one viewer's state from another's) when it
439
+ // can be computed per-viewer; this broadcast path's entire point is that every viewer gets an
440
+ // identical payload. A relevanceRadius-configured world is the one this budgeter targets.
441
+ const entitySnap = appRuntime.getSnapshot()
442
+ const combined = { tick: playerSnap.tick, players: playerSnap.players, entities: entitySnap.entities, serverTime: serverNow }
443
+ const prevMap = (isKeyframe || state.broadcastEntityMap.size === 0) ? new Map() : state.broadcastEntityMap
444
+ const { encoded, entityMap } = SnapshotEncoder.encodeDelta(combined, prevMap)
445
+ state.broadcastEntityMap = entityMap
446
+ const data = packSnapshot(snapshotSeq, encoded)
447
+ for (const player of players) {
448
+ if (!isKeyframe && player.snapGroup % snapGroups !== curGroup) continue
449
+ connections.sendPacked(player.id, data, SNAP_UNRELIABLE, MSG.SNAPSHOT)
450
+ }
451
+ }
452
+ }
453
+
454
+ export function createTickHandler(deps) {
455
+ // 60Hz default (was 128) -- mirrors src/sdk/server.js's config.tickRate||60; every real caller passes
456
+ // tickRate explicitly, this is only a defensive fallback.
457
+ const { networkState, playerManager, physicsIntegration, lagCompensator, physics, appRuntime, connections, movement: m = {}, stageLoader, getRelevanceRadius, _movement, tickRate = 60, getWorldTimeOfDayConfig, getWorldWeatherConfig } = deps
458
+ // Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync). Passed the LIVE
459
+ // getWorldTimeOfDayConfig ACCESSOR (not a pre-resolved value) -- ServerTimeOfDay.js re-reads it lazily on
460
+ // every tick, since ctx.currentWorldDef is NOT yet populated at TickHandler-construction time (see
461
+ // ServerTimeOfDay.js's header comment for the real bug this fixes: a construction-time-only read always
462
+ // saw worldDef===undefined and permanently disabled itself, live-witnessed with a real 2-client WS
463
+ // harness against tps-game before this fix). Absent getWorldTimeOfDayConfig, or a config with
464
+ // serverAuthoritative!==true, both leave this fully inert -- a caller that never passes it (or a world
465
+ // without terrain.timeOfDay) sees zero behavior change.
466
+ const serverTimeOfDay = createServerTimeOfDay(getWorldTimeOfDayConfig)
467
+ // Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync). Same lazy-
468
+ // accessor discipline as serverTimeOfDay immediately above (getWorldWeatherConfig re-read on every
469
+ // isEnabled()/getSyncPayload() call, not resolved once at construction) for the identical reason: this
470
+ // module is constructed before ctx.currentWorldDef is populated. Unlike serverTimeOfDay, ServerWeather
471
+ // has no per-tick advance step -- it is a discrete state broadcast on CHANGE (see shouldBroadcast's
472
+ // dirty flag), not a continuously-advancing clock re-broadcast on a fixed cadence.
473
+ const serverWeather = createServerWeather(getWorldWeatherConfig)
474
+ const KEYFRAME_INTERVAL = tickRate * 10
475
+ let _snapshotInterval = 1
476
+ let _snapRateAdjustTick = 0
477
+ let _lastSnapRate = tickRate
478
+ // opt-in: process.memoryUsage() + template string per keyframe log is real cost, gated off by default; SPOINT_TICK_PROFILE=1 or deps.enableProfiling enables
479
+ const _PROFILE = deps.enableProfiling || (typeof process !== 'undefined' && process.env?.SPOINT_TICK_PROFILE === '1')
480
+ const applyMovement = _movement?.applyMovement || _applyMovement
481
+ const DEFAULT_MOVEMENT = _movement?.DEFAULT_MOVEMENT || _DEFAULT_MOVEMENT
482
+ const movement = { ...DEFAULT_MOVEMENT, ...m }
483
+ const mvDeps = { playerManager, physicsIntegration, lagCompensator, networkState, applyMovement, movement, eventLog: deps.eventLog, transformRingWriter: deps.transformRingWriter || null }
484
+ // playerScratch: per-player pooled { entities:[], removed:[], spareMap:Map } reused every tick instead
485
+ // of allocating fresh entities/removed arrays and a fresh nextMap per player per tick (128Hz x N
486
+ // clients -- the dominant GC-pressure source this pools away). spareMap is the OTHER half of a
487
+ // double-buffer with playerEntityMaps.get(id): each tick, encodeDeltaFromCache writes into spareMap
488
+ // while reading the current playerEntityMaps entry as prevEntityMap, then the two are swapped -- so a
489
+ // map is never cleared/reused while it is still this call's prevEntityMap (that would erase the very
490
+ // data the delta is being computed against), and it only becomes the write target again once it has
491
+ // aged out one full tick as the (now-stale, already-consumed) prevEntityMap.
492
+ const playerScratch = new Map()
493
+ function getPlayerScratch(id) {
494
+ let s = playerScratch.get(id)
495
+ if (!s) { s = { entities: [], removed: [], spareMap: new Map() }; playerScratch.set(id, s) }
496
+ return s
497
+ }
498
+ // getSnapshotHz: a live accessor (not a captured value) so player-LOD REDUCED-tier throttling
499
+ // (see buildAndSendSnapshots) always derives its ~5Hz on-wire cadence from the CURRENT adaptive
500
+ // snapshot rate (_lastSnapRate, updated by _computeSnapshotInterval below as player count/RTT/cost
501
+ // change), not a stale boot-time tickRate.
502
+ const snapDeps = { connections, stageLoader, getRelevanceRadius, networkState, playerEntityMaps: new Map(), playerScratch, getPlayerScratch, getSnapshotHz: () => _lastSnapRate }
503
+ // cellEntityMaps/cellLastTick: the per-CELL delta baseline + tombstone cursor that makes shared
504
+ // per-cell encoding real (see the useSharedCell branch in buildAndSendSnapshots) -- one Map/tick
505
+ // number per unique AOI cell any player currently occupies, NOT per player. playerCell tracks which
506
+ // cell each player's own last-received snapshot was baselined against, so a player who just joined a
507
+ // cell (or crossed into it from another) is detected and given a one-time full resync instead of an
508
+ // unsafe delta against baseline ticks they never saw.
509
+ const snapState = { broadcastEntityMap: new Map(), staticEntityMap: new Map(), staticEntityIds: null, lastStaticEntries: null, lastStaticVersion: -1, lastStaticCustomSum: -1, lastDynVersion: -1, prevDynCache: null, tombstoneLog: new TombstoneLog(), knownIds: null, playerLastTick: new Map(), cellEntityMaps: new Map(), cellLastTick: new Map(), playerCell: new Map() }
510
+ const playerIdleCounts = new Map(), playerAccumDt = new Map()
511
+ const grid = new Map(), gridCells = new Map()
512
+ let snapshotSeq = 0, profileLog = 0, profileSum = 0, profileSumSnap = 0, profileSumPhys = 0, profileSumMv = 0, profileCount = 0
513
+ let _lastBudgetWarnMs = 0
514
+
515
+ let _lastBandHz = tickRate
516
+ let _rateChangeTick = 0
517
+ // Real measured per-tick snapshot-build cost (EMA), fed from buildAndSendSnapshots' own wall time on
518
+ // every tick a snapshot actually sends -- see _snapCostEmaMs update in onTick below. Player COUNT alone
519
+ // is a proxy for "how expensive is this tick's snapshot work" that silently diverges from the real
520
+ // driver: buildAndSendSnapshots' cost scales with relevance-filtered nearby-player/entity PAIRS within
521
+ // each viewer's radius, not raw connected-player count -- a small dense crowd (everyone clustered,
522
+ // mutually relevant) can cost far more per tick than a larger but spread-out population where most
523
+ // players fall outside each other's relevanceRadius and get filtered out cheaply. SNAP_COST_HIGH_FRAC/
524
+ // SNAP_COST_LOW_FRAC mirror the existing avgRtt high/low thresholds' shape (a real-measurement throttle
525
+ // layered on top of the player-count band, not a replacement -- the band still provides a safe
526
+ // cold-start default before any snapshot has been measured).
527
+ let _snapCostEmaMs = 0
528
+ const SNAP_COST_EMA_ALPHA = 0.2
529
+
530
+ function _computeSnapshotInterval(players, tick) {
531
+ const pc = players.length
532
+ let bandHz = tickRate
533
+ if (pc === 0) {
534
+ bandHz = SNAP_RATE_IDLE_HZ
535
+ } else if (pc <= SNAP_PLAYER_LOW) {
536
+ bandHz = SNAP_RATE_MAX_HZ
537
+ } else if (pc >= SNAP_PLAYER_HIGH) {
538
+ bandHz = SNAP_RATE_MIN_HZ
539
+ } else {
540
+ const t = (pc - SNAP_PLAYER_LOW) / (SNAP_PLAYER_HIGH - SNAP_PLAYER_LOW)
541
+ bandHz = Math.round(SNAP_RATE_MAX_HZ - t * (SNAP_RATE_MAX_HZ - SNAP_RATE_MIN_HZ))
542
+ }
543
+ const rateDiff = bandHz - _lastBandHz
544
+ const tickSinceChange = tick - _rateChangeTick
545
+ // Hysteresis applies ONLY to the player-count BAND (damps flapping as players join/leave near a band
546
+ // edge) -- it must never gate whether RTT/real-cost gets RE-EVALUATED, or a population that settles
547
+ // into a stable band (the common case) permanently freezes the RTT/cost throttles at whatever they
548
+ // read the one time the band last changed. (Found live: a population stable at pc=3 for its whole
549
+ // session never re-read avgRtt after the initial band settle, even after RTT spiked to 300ms well
550
+ // past SNAP_RTT_HIGH=200 -- the rate stayed pinned at the pre-spike value forever.) So bandHz is
551
+ // damped here, but avgRtt/_snapCostEmaMs are read and applied fresh on EVERY call.
552
+ const targetHzBase = (Math.abs(rateDiff) <= 2 || tickSinceChange < tickRate * 2) ? _lastBandHz : bandHz
553
+ if (targetHzBase !== _lastBandHz) { _lastBandHz = targetHzBase; _rateChangeTick = tick }
554
+ let targetHz = targetHzBase
555
+ let avgRtt = 0
556
+ try {
557
+ const conns = connections?.clients
558
+ if (conns && conns.size > 0) {
559
+ let rttSum = 0, rttCount = 0
560
+ for (const client of conns.values()) {
561
+ if (client.rtt != null) { rttSum += client.rtt; rttCount++ }
562
+ }
563
+ if (rttCount > 0) avgRtt = rttSum / rttCount
564
+ }
565
+ } catch (_) {}
566
+ if (avgRtt > SNAP_RTT_HIGH) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
567
+ else if (avgRtt > SNAP_RTT_LOW) targetHz = Math.round(targetHz * 0.75)
568
+ if (avgRtt < SNAP_RTT_LOW && targetHz < SNAP_RATE_MAX_HZ) targetHz = Math.min(SNAP_RATE_MAX_HZ, targetHz + 2)
569
+ // Real-cost throttle: a dense/clustered crowd measured expensive to snapshot (regardless of what the
570
+ // player-count band alone would pick) pulls the rate down further, same direction+shape as the RTT
571
+ // adjustment above but driven by actual measured compute, not an assumed-uniform per-player cost.
572
+ const tickBudgetMs = 1000 / tickRate
573
+ if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_HIGH_FRAC) targetHz = Math.max(SNAP_RATE_MIN_HZ, Math.round(targetHz * 0.5))
574
+ else if (_snapCostEmaMs > tickBudgetMs * SNAP_COST_LOW_FRAC) targetHz = Math.round(targetHz * 0.75)
575
+ return Math.max(1, Math.round(tickRate / Math.max(SNAP_RATE_IDLE_HZ, Math.min(SNAP_RATE_MAX_HZ, targetHz))))
576
+ }
577
+
578
+ // simulateTick: the PURE deterministic-simulation subset of a tick -- movement -> player collisions ->
579
+ // physics.step -> appRuntime.tick -- with ZERO network I/O side effects (no snapshot build, no
580
+ // connections.broadcast/emit, no networkState.setTick/rate-adjust bookkeeping). This is exactly the
581
+ // slice rollback-tickhandler-resimulate-loop's rewind+replay-forward orchestration (RollbackLoop.js)
582
+ // needs to call once per resimulated tick: onTick's snapshot/broadcast half is a real one-time-only
583
+ // wire side effect (it would double-send stale snapshots for every already-broadcast historical tick
584
+ // if replayed) and must never re-run, but the physics/app simulation half is exactly what a correct
585
+ // GGPO-style resimulate pass re-executes with corrected input. Returns nothing; mutates players/physics/
586
+ // appRuntime state in place, identically to what onTick's own inline sequence below does -- onTick
587
+ // calls this function rather than duplicating the sequence, so the two can never drift apart.
588
+ function simulateTick(tick, dt, players) {
589
+ processPlayerMovement(players, mvDeps, tick, dt, playerIdleCounts, playerAccumDt)
590
+ const cellSz = physicsIntegration.config.capsuleRadius * 8, minDist = physicsIntegration.config.capsuleRadius * 2
591
+ applyPlayerCollisions(players, grid, gridCells, cellSz, minDist * minDist, minDist, dt, physicsIntegration)
592
+ // must run before physics.step: drains VegPhysics/RockPhysics streamer-queued collider add/remove into Jolt's broadphase
593
+ if (typeof physics.drainBodyQueue === 'function') physics.drainBodyQueue()
594
+ physics.step(dt)
595
+ appRuntime.tick(tick, dt)
596
+ }
597
+
598
+ function onTick(tick, dt) {
599
+ const t0 = performance.now()
600
+ const serverNow = Date.now()
601
+ networkState.setTick(tick, serverNow)
602
+ const players = playerManager.getConnectedPlayers()
603
+
604
+ if (tick - _snapRateAdjustTick >= SNAP_RATE_ADJUST_INTERVAL) {
605
+ _snapRateAdjustTick = tick
606
+ _snapshotInterval = _computeSnapshotInterval(players, tick)
607
+ if (players.length > 0 && connections) {
608
+ _lastSnapRate = Math.round(tickRate / _snapshotInterval)
609
+ connections.emit('snapshot-rate', { rate: _lastSnapRate, tick, interval: _snapshotInterval })
610
+ }
611
+ }
612
+
613
+ const t1pre = performance.now()
614
+ simulateTick(tick, dt, players)
615
+ const t4 = performance.now()
616
+ // sub-phase split points (mv/col/phys) are no longer individually measurable now that simulateTick is
617
+ // one opaque call shared with the rollback resimulate path (simulateTick must stay a single indivisible
618
+ // unit so the resimulate loop replays EXACTLY what onTick would have run, never a hand-picked subset of
619
+ // its internal phases) -- t1/t2 collapse to t1pre and t3 to t4 so the profiler's mv/col/phys buckets
620
+ // report the combined simulateTick total under `phys` rather than silently reporting a fabricated
621
+ // (always-zero) split; sync/respawn/etc's OWN sub-timers (appRuntime._lastSyncMs etc, logged separately
622
+ // below) still carry the fine-grained post-simulateTick detail.
623
+ const t1 = t1pre, t2 = t1pre, t3 = t4
624
+ if (players.length > 0 && tick % _snapshotInterval === 0) {
625
+ snapshotSeq++
626
+ buildAndSendSnapshots(players, appRuntime, snapDeps, tick, snapshotSeq, snapshotSeq % KEYFRAME_INTERVAL === 0, snapState, serverNow)
627
+ // EMA of the REAL measured snapshot-build wall time, isolated to just this call (not the cleanup
628
+ // loop/auto-save below) -- feeds _computeSnapshotInterval's real-cost throttle so a dense/clustered
629
+ // crowd that's expensive to snapshot self-corrects even when raw connected-player count is low.
630
+ const _snapCostMs = performance.now() - t4
631
+ _snapCostEmaMs = _snapCostEmaMs === 0 ? _snapCostMs : (_snapCostEmaMs * (1 - SNAP_COST_EMA_ALPHA) + _snapCostMs * SNAP_COST_EMA_ALPHA)
632
+ }
633
+ // ~1Hz broadcast of every connected client's server-measured RTT (the same EWMA client.rtt already
634
+ // computed per-HEARTBEAT in ServerHandlers.js, reused here rather than re-measuring). This is the data
635
+ // a P2P/wireweave room's host-migration election (client/HostMigration.js) needs: in a star topology
636
+ // (only the host has an RTC data channel to each joiner) there is no peer-to-peer ping mesh, so every
637
+ // joiner learning the SAME server-observed RTT numbers is the only way they can all independently agree
638
+ // on the same "lowest-ping remaining peer" winner without a vote round-trip. Harmless on the plain WS
639
+ // server path too (clients that never look at PEER_RTT_TABLE simply ignore it) -- kept unconditional
640
+ // rather than gated on a P2P flag so a WS-hosted room could reuse the same election code path later.
641
+ if (players.length > 0 && tick % tickRate === 0) {
642
+ const rttTable = {}, pubkeys = {}
643
+ for (const p of players) {
644
+ const c = connections.getClient(p.id)
645
+ if (!c) continue
646
+ if (c.rtt != null) rttTable[p.id] = c.rtt
647
+ // Only populated for wireweave P2P peers (see ConnectionManager.addClient) -- lets every joiner
648
+ // resolve a server playerId from this table back to the wireweave pubkey it needs to reconnect a
649
+ // data channel to during host migration (client/HostMigration.js). Absent/empty on the plain WS path.
650
+ if (c.peerPubkey) pubkeys[p.id] = c.peerPubkey
651
+ }
652
+ connections.broadcast(MSG.PEER_RTT_TABLE, { rtt: rttTable, pubkeys })
653
+ }
654
+ // Server-authoritative day-cycle clock (server-clock-synced-time-of-day-network-sync): advance every
655
+ // tick (real elapsed dt, matching TimeOfDay.js's own local update() formula) so the fraction stays
656
+ // correct regardless of snapshot/broadcast cadence, but only BROADCAST the coarse correction on
657
+ // serverTimeOfDay's own ~5s real-time cadence (see ServerTimeOfDay.js's shouldBroadcast). Both calls
658
+ // are no-ops when the world never opted in (worldDef.terrain.timeOfDay.serverAuthoritative!==true).
659
+ serverTimeOfDay.tick(dt)
660
+ if (players.length > 0 && serverTimeOfDay.shouldBroadcast()) {
661
+ connections.broadcast(MSG.TIME_OF_DAY_SYNC, serverTimeOfDay.getSyncPayload())
662
+ }
663
+ // Server-authoritative weather state (weather-server-driven-state-and-multiplayer-sync): no per-tick
664
+ // advance (unlike serverTimeOfDay above) -- shouldBroadcast only returns true once per real state
665
+ // CHANGE (first activation, or a future setState() call from an admin/game-mode toggle), so this is a
666
+ // cheap dirty-flag check every tick, not a real broadcast most ticks. No-op when the world never
667
+ // opted in (worldDef.terrain.weather.serverAuthoritative!==true).
668
+ if (players.length > 0 && serverWeather.shouldBroadcast()) {
669
+ connections.broadcast(MSG.WEATHER_SYNC, serverWeather.getSyncPayload())
670
+ }
671
+ if (tick % (tickRate * AUTO_SAVE_INTERVAL) === 0 && tick > 0) {
672
+ try { deps.onAutoSave?.() } catch (_) {}
673
+ }
674
+ for (const id of snapDeps.playerEntityMaps.keys()) { if (!playerManager.getPlayer(id)) { snapDeps.playerEntityMaps.delete(id); playerIdleCounts.delete(id); playerAccumDt.delete(id); _priorityAccumulators.delete(id); playerScratch.delete(id); snapState.playerLastTick.delete(id); snapState.playerCell.delete(id) } }
675
+ const t5 = performance.now()
676
+ try { appRuntime._drainReloadQueue() } catch (e) { console.error('[TickHandler] reload queue error:', e.message) }
677
+ if (players.length > 0) {
678
+ profileSum += t5-t0; profileSumSnap += t5-t4; profileSumPhys += t3-t2; profileSumMv += t1-t0; profileCount++
679
+ // server-scale-prometheus-metrics-endpoint-dashboard: same real per-phase durations the existing
680
+ // profileSum* accumulators/console.log(_PROFILE) already compute, additionally fed into the
681
+ // Prometheus histogram registry so a scrape sees the full distribution, not just a periodic log line.
682
+ recordTickPhase('total', t5-t0); recordTickPhase('mv', t1-t0); recordTickPhase('phys', t3-t2); recordTickPhase('snap', t5-t4)
683
+ }
684
+ // rate-limited overrun warning: silent tick overrun is what causes pacing to fall behind under load with no visibility
685
+ const tickBudgetMs = 1000 / tickRate
686
+ if (t5 - t0 > tickBudgetMs * 2 && serverNow - _lastBudgetWarnMs > 1000) {
687
+ _lastBudgetWarnMs = serverNow
688
+ console.warn(`[TickHandler] tick ${tick} overran budget: ${(t5-t0).toFixed(2)}ms > ${(tickBudgetMs*2).toFixed(2)}ms (budget ${tickBudgetMs.toFixed(2)}ms) players:${players.length}`)
689
+ }
690
+ if (_PROFILE && ++profileLog % KEYFRAME_INTERVAL === 0) {
691
+ const total=t5-t0, mem=typeof process!=='undefined'?process.memoryUsage():{heapUsed:0,rss:0,external:0,arrayBuffers:0}, avg=n => profileCount>0?(n/profileCount).toFixed(2):'0'
692
+ const mb=n=>(n/1048576).toFixed(1)
693
+ const dynIds=appRuntime._dynamicEntityIds?.size||0, activeDyn=appRuntime.getActiveDynamicIds()?.size||0
694
+ const avgTotal=avg(profileSum),avgSnap=avg(profileSumSnap),avgPhys=avg(profileSumPhys),avgMv=avg(profileSumMv)
695
+ profileSum=0; profileSumSnap=0; profileSumPhys=0; profileSumMv=0; profileCount=0
696
+ let idleSkipped = 0; if (players.length > 0) for (const c of playerIdleCounts.values()) if (c >= 2) idleSkipped++
697
+ const physSkipped = players.length > 0 ? playerAccumDt.size : 0
698
+ try { console.log(`[tick-profile] tick:${tick} players:${players.length} idle:${idleSkipped} physSkip:${physSkipped} entities:${appRuntime.entities.size} dynIds:${dynIds} activeDyn:${activeDyn} total:${total.toFixed(2)}ms(avg:${avgTotal}) | mv:${(t1-t0).toFixed(2)}(avg:${avgMv}) col:${(t2-t1).toFixed(2)} phys:${(t3-t2).toFixed(2)}(avg:${avgPhys}) app:${(t4-t3).toFixed(2)} sync:${(appRuntime._lastSyncMs||0).toFixed(2)} respawn:${(appRuntime._lastRespawnMs||0).toFixed(2)} spatial:${(appRuntime._lastSpatialMs||0).toFixed(2)} col2:${(appRuntime._lastCollisionMs||0).toFixed(2)} int:${(appRuntime._lastInteractMs||0).toFixed(2)} snap:${(t5-t4).toFixed(2)}(avg:${avgSnap}) | heap:${mb(mem.heapUsed)}MB rss:${mb(mem.rss)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`) } catch (_) {}
699
+ }
700
+ }
701
+
702
+ // Attached (not just closed-over) so a late-joining player's connect handler -- ServerHandlers.js's
703
+ // onClientConnect, which runs OUTSIDE this closure -- can read the CURRENT fraction for a one-time
704
+ // join-time send, mirroring the existing ctx._terrainStreamer attach-after-create convention (see
705
+ // WorkerEntry.js/ServerAPI.js). onTick itself is unused as a namespace by any caller today (setTickHandler
706
+ // only ever calls it as a plain function), so this adds a read surface without touching that contract.
707
+ onTick.serverTimeOfDay = serverTimeOfDay
708
+ // server-scale-prometheus-metrics-endpoint-dashboard: a live read of the SAME profileSum*/profileCount
709
+ // accumulators the existing _PROFILE console.log path already computes unconditionally every tick (see
710
+ // the profileSum block above -- computed regardless of _PROFILE, only the console.log itself is gated).
711
+ // Deliberately does NOT reset the accumulators on read (unlike the console.log path, which resets every
712
+ // KEYFRAME_INTERVAL ticks) -- a Prometheus scrape is pull-based and may poll at an arbitrary cadence
713
+ // uncoordinated with KEYFRAME_INTERVAL, so resetting on read here would make one scraper's read starve
714
+ // a concurrent scraper's window; ServerAPI.js's /metrics route instead reads this on every request and
715
+ // reports the average over however many ticks have accumulated since the last natural profileLog reset.
716
+ onTick.getMetrics = () => ({
717
+ avgTotalMs: profileCount > 0 ? profileSum / profileCount : 0,
718
+ avgMvMs: profileCount > 0 ? profileSumMv / profileCount : 0,
719
+ avgPhysMs: profileCount > 0 ? profileSumPhys / profileCount : 0,
720
+ avgSnapMs: profileCount > 0 ? profileSumSnap / profileCount : 0,
721
+ sampleCount: profileCount,
722
+ })
723
+ // rollback-tickhandler-resimulate-loop: exposes the pure deterministic-simulation subset (see
724
+ // simulateTick's own header comment) so RollbackLoop.js can replay ticks with corrected input without
725
+ // re-triggering this handler's network-broadcast side effects. Same attach-after-create convention as
726
+ // serverTimeOfDay/serverWeather below -- a plain function property on the returned onTick closure.
727
+ onTick.simulateTick = simulateTick
728
+ // rollback-tickhandler-resimulate-loop: playerIdleCounts/playerAccumDt (processPlayerMovement's physics-
729
+ // decimation scheduling state, see the isIdle/accumDt block above) are tick-history-dependent hidden state
730
+ // that is NOT part of PhysicsWorld.snapshotBodies/snapshotCharacters -- a real bug found+fixed while
731
+ // building RollbackLoop.js's live witness: resimulating from a restored physics snapshot WITHOUT also
732
+ // restoring these two maps to their tick-30 values reproduced a real 0.27m/1.6(m/s) divergence even when
733
+ // replaying the IDENTICAL scripted input the original forward run used, because the maps still held their
734
+ // post-tick-40 values from the original run (an idle player who had already accumulated 9 skipped ticks'
735
+ // worth of decimation state by tick 40 does not skip-decimate the same way on a resim starting fresh from
736
+ // tick 30). snapshotSimState/restoreSimState expose exactly these two Maps (cloned, never the live
737
+ // reference) so a rollback caller saves/restores them in lockstep with the physics snapshot every tick.
738
+ onTick.snapshotSimState = () => ({ playerIdleCounts: new Map(playerIdleCounts), playerAccumDt: new Map(playerAccumDt) })
739
+ onTick.restoreSimState = (s) => {
740
+ if (!s) return
741
+ playerIdleCounts.clear(); for (const [k, v] of s.playerIdleCounts) playerIdleCounts.set(k, v)
742
+ playerAccumDt.clear(); for (const [k, v] of s.playerAccumDt) playerAccumDt.set(k, v)
743
+ }
744
+ // Same attach-after-create convention as serverTimeOfDay above, for the identical reason: ServerHandlers.js's
745
+ // onClientConnect (outside this closure) needs to read the CURRENT weather state for a one-time
746
+ // join-time backfill send.
747
+ onTick.serverWeather = serverWeather
748
+ // lockstep-desync-wireweave-transport-and-tickhandler-wiring: the TickHandler-side half of wiring
749
+ // DesyncDetector.js/LockstepChecksum.js into a real lockstep peer's tick loop, reusing simulateTick
750
+ // exactly as RollbackLoop.js's resimulate path already does above (the pure deterministic-simulation
751
+ // subset, zero network-broadcast side effects -- a lockstep peer's own tick loop, unlike this file's
752
+ // own onTick, never calls buildAndSendSnapshots at all: a P2P mesh peer has no "clients to snapshot",
753
+ // every peer IS a full simulation, see LockstepTickSystem.js's header comment for why this driver
754
+ // exists as a wholly separate onTick(tick,dt) consumer from the server-authoritative one above).
755
+ //
756
+ // simulateTickWithChecksum(tick, dt, players): runs simulateTick unchanged, then -- only on ticks
757
+ // detector.isChecksumTick(tick) selects (see DesyncDetector.js's own cadence-owning comment) --
758
+ // computes this peer's own checksumBodies(tick, physics.snapshotBodies()) and reports it through
759
+ // `desyncTransport.reportLocalChecksum`, which both broadcasts it to the mesh AND feeds the local
760
+ // detector, matching submitLocalInput's identical local-write-goes-through-the-same-path discipline
761
+ // in LockstepInputTransport.js. Returns the detector's resolution result ({status:'verified'|'desync',
762
+ // ...}) on a checksum tick that JUST became fully resolved by this peer's OWN report (the common case
763
+ // when this peer is the last of the roster to report), or null on every other tick -- a caller that
764
+ // wants to observe every resolution (including ones resolved by a remote peer's LATER-arriving report)
765
+ // should use detector.onVerified/onDesync instead, exactly as constructed; this return value is a
766
+ // same-tick convenience for a caller that only cares about its own report's synchronous outcome.
767
+ onTick.attachDesyncChecksum = (desyncTransport, checksumFn) => {
768
+ const detector = desyncTransport.detector
769
+ const physics_ = desyncTransport.physics
770
+ const computeChecksum = checksumFn || ((t) => checksumBodies(t, physics_.snapshotBodies()))
771
+ return function simulateTickWithChecksum(tick, dt, players) {
772
+ simulateTick(tick, dt, players)
773
+ if (!detector.isChecksumTick(tick)) return null
774
+ const checksum = computeChecksum(tick)
775
+ return desyncTransport.reportLocalChecksum(tick, checksum)
776
+ }
777
+ }
778
+ return onTick
779
+ }