engine v5.1.0
Connection
June 18, 2026
what's new
- Sound effects load reliably the first time you trigger them — no more silent first clicks while assets warm up, and sounds that haven't been generated yet now cook themselves instead of going quiet forever.
- Game sounds and music download much smaller on browsers that can play Opus — same audio, faster loads. Nothing changes about how sounds get added, and browsers without Opus support keep getting the mp3 automatically.
- Multiplayer got a major upgrade under the hood. Your own moves, hits, and actions now happen instantly and never rubberband — and worlds stay smooth even when the server is busy or more friends pile in. Nothing to change in your game; it just works.
- Walking across big worlds no longer causes constant background terrain rebuilds — smoother frames while exploring.
- Ground and collision now build reliably even when asset processing is backed up — fixes falling through the world (or bouncing in place) after exploring while models were still generating.
- Animated material effects no longer cause frame hitches while they play.
- Fixed controls staying dead even after the engine tried to recover the connection — completes the 5.0.15 input fix.
- Fixed controls staying dead on a long session even after the engine tried to recover — completes the input-recovery fix.
- Animated material effects now update instantly with zero hitches — param changes never rebuild shaders.
- Objects that use a custom material now pop in smoothly the first time they appear — the engine compiles their shaders ahead of time in the background, so there's no stutter when a new one shows up.
- When the game server falls behind, players now see a "Server running behind…" notice at the top of the screen — if you spot it, tell us! It means lag is coming from the server being overloaded (often heavy onTick scripts or huge object counts), not your connection, and the report helps us find it fast.
- Long-running game servers no longer slow down over hours of play — fixes worlds getting laggier the longer a room stays up.
- Enemy waves and crowds no longer freeze the game when they spawn — crowd shaders compile once and get reused.
- Saves, inventories, settings, achievements, and progression now follow the logged-in player identity more reliably across joins and rooms.
- Existing games that wrote saves under
player/...keys need their scripts migrated touser/${objectApi.userId}/.... - Fixed worlds where ground, water, or shadowed objects could turn invisible while editing — a texture set to pixelated (nearest) filtering, or a depth effect without its compare mode, could silently knock out the whole shader for everything drawn with it. Those materials now draw correctly instead of disappearing.
- Other players can see your shots and visual effects in client-authoritative multiplayer games.
- Recovering from a long stall now skips old gameplay history instead of rewinding and replaying movement and effects.
- Characters in new 3D games now face where the camera is looking, including while strafing or moving backward.
- Savi now preserves correct character facing when building first-person cameras.
- Drawn-in-code textures no longer get stuck on the loading spinner forever — if the engine can't bake one, it now says so in the logs instead of hanging.
- Drawn-in-code terrain and object textures now load reliably after joining, reconnecting, or changing places.
- Drawn-in-code sprite animations now play normally instead of remaining on their first frame.
- Drawn-in-code textures now work on objects created or skinned by your scripts at runtime (spawned pickups, the player's facing art, etc.), not just objects placed directly in the scene.
- Multiplayer damage, knockback, and similar effects now land reliably even while players' other state is changing.
- Other players can now consistently see projectile effects such as Fireballs in multiplayer.
- Fixed multiplayer guests seeing the world flash in and out with severe lag after reconnecting or resynchronizing.
- Fixed multiplayer guests seeing large sections of the world repeatedly disappear and reappear during normal play.
- Fixed VFX becoming invisible to other players when cast from client-owned gameplay objects.
- Fixed projectile effects sometimes being invisible to other players in multiplayer while direct effects such as Flamethrower remained visible.
- Short-lived effects cast by other players now appear reliably in multiplayer, including under latency.
- Player and controlled-vehicle movement now stays smooth in multiplayer without making your own controls or camera feel delayed.
- Solo games no longer freeze when player-attached objects such as balloons, equipment, or effects are built as child objects.
›technical notes
- Audio loading reliability: clip fetches carry a 12s timeout that counts toward the 3-attempt budget (a hung response can no longer park a clip in
pendingforever);/cdnclips probe via the magic-cdn async rails (x-magic-cdn-async) so missing sounds trigger their own generation and 202 still-generating answers retry on Retry-After without burning attempts; pre-session 401s cooldown-park without burning attempts and the cdn-session bootstrap signal re-arms terminally-failed clips; spec/script sound refs warm at explicit priority the moment a spec snapshot applies (the warmer no longer starves explicit warms behind live loads); renderergetStatsaddsnotReadyStarts/failedClipCount/timedOutFetches/stillGeneratingResponses. - Audio clips served from Magic CDN (
/cdn/*.mp3,/magic/*.mp3) now load through the derived Ogg Opus transport (<name>.mp3.opus— the KTX2 companion pattern for audio) whenever the browser can actually decode it. The two playback paths gate separately: the buffered path (fetch +decodeAudioData) gates on a one-time probe decode of an embedded 137-byte Ogg Opus blob, because Safari'scanPlayTypeanswers "maybe" for Ogg Opus while its Web AudiodecodeAudioDatarejects it in every shipping Safari; the streaming path (HTMLAudioElement) gates oncanPlayType('audio/ogg; codecs="opus"')plus a one-shoterrorfallback that swaps the element back to the mp3 source. Any opus fetch/decode/stream failure marks that clip and it loads as plain mp3 for the rest of its registration (clipHandle/releaseclear the mark); the buffered downgrade falls through to mp3 inside the same prefetch call, so it never consumes one of the 3 mp3 retry attempts and never marks the clip terminally failed. Specs and Savi keep writing.mp3— the swap is transport-level. Vibe samples (/cdn/vibe-samples/) are untouched. - External audio URLs in specs (
.mp3/.wav/.flac) now reroute from the/api/mediabyte proxy to the Magic CDN public import lane (/cdn/public.<b64>.mp3): the first request imports the file (slower than a straight proxy) and the served bytes are normalized/re-encoded (mp3 identity + lossless master) rather than proxied verbatim; a failed import tombstones briefly instead of falling back to raw proxied bytes. Once imported, these clips get the same.opuscompanion transport as any CDN mp3.m4a/aac/oggURLs still proxy verbatim through/api/media(no AAC decoder in the import chain; ogg commonly carries video). - The opus companion rides the audio loader's existing Magic CDN probe/timeout machinery (from the audio-loading-reliability work) rather than any bespoke polling: the companion URL goes through the same async-header probe, per-fetch timeout, 202 + Retry-After no-fault retry cadence, and cdn-session gating as the mp3 it shadows — still-generating waits and pre-session 401s park the clip without consuming its 3-attempt retry budget on either transport, while a terminal opus failure (probe 4xx/5xx, failed fetch, empty payload, failed decode, timeout) downgrades that clip to mp3 within the same load.
- BREAKING (authority model): rewrote multiplayer to be client-authoritative with per-entity single-writer ownership.
"multiplayer"(the default) now IS the client-auth model; the server-auth predicted pipeline (compare / resim / rollback) is deleted from the tree. A spec pinned with the legacy"client-auth"value parses and aliases to"multiplayer". Rationale:docs/networking-modes.purpose.md; build contract:docs/client-auth-networking-plan.md. - Authority: every entity has exactly one simulator. A client simulates its own ownership envelope (local player + spawn chain) as final; each place's unowned remainder (NPC behaviors, triggers/liquids, tweens, nav, events) is simulated by a host client the server assigns in
TomePlaceHosts(replicated, reset-proof, per-place epoch, notified over Control). The server validates, applies, sequences, and fans out — and simulates nothing. There is no resimulation, rollback, or misprediction. - Upstream channel
RoomClientOpcode.StateDeltas: owners/hosts upload per-tick component deltas. The server authorizes each row against sender ownership — create-chain attribution, per-componentclientAuthWritepolicy (blocks identity/permission/routing rewrites), per-client flood budgets, and byte caps (1 MiB/message, 64 entries/row, 64 KiB/component value). Host messages stamp{hostedPlaceId, epoch}; stale stamps strip to the envelope gate instead of dropping the message. - Host migration keys on events, not grace windows: socket detach, hidden-tab Suspended, place travel, and upload-silence reassign immediately at
epoch+1(adopt-without-firing so migration never re-fires contacts). Empty places pause (single writer holds vacuously) and re-host on the next entrant. Observer clients are fully passive. - Server physics removed for client-auth places (no stepping, bodies, or runtime warming); uploaded poses are plain component writes.
ObjectAPIphysics mutators/queries degrade loudly server-side; proximity rides the spatial index. Hidden-tab owners freeze (no server fallback simulation). - Write-through intents keep Savi's natural code correct by construction in both modes: cross-writer effects forward to the owning simulator (
rail.interact/emit/enterPlace/triggerPurchase/destroy,rail.voxelEditthroughapplyVoxelTerrainEdit,withPersistenceoverSpecMutationswith per-mutation provenance + a creator gate on global/authoring mutations). - Observability: per-phase server tick budget (preUpdate/input/simulation/postUpdate/replication) in
ServerRuntimeTelemetry+ F3 overlay +room.server_behind.episodeDatadog log;netcode.state_delta.*security-boundary events log through winston; upload counters in the F3 server section. e2e suites drive real server+client runtimes through the real wire codecs (client-auth-e2e.test.ts,client-auth-host-migration-e2e.test.ts). Multiplayer and singleplayer remain byte-identical across the change. - Known follow-ups (named in the plan doc): owner-side envelope trigger/liquid lane (hooks currently fire nowhere in client-auth after the server-physics cut) and rapid same-chunk voxel-edit echo flicker (feel-sensitive; needs in-engine validation).
- Appended-band LOD downgrades are lazy (ledger #639): in the client extended-distance terrain streamer, when a resident chunk's current AND wanted LOD are both in the appended far bands (
appendExtendedFarBands), the downgrade re-key is suppressed until wanted lags current by 2 bands — quality only ever ≥ wanted, residency bounded at one band finer than wanted. At 5.0.7 the sole-streamer fix made the extended desktop band (~4k chunks) genuinely resident, so walking caused ~26 full chunk rebuilds/sec of UNCHANGED terrain from band flips (measured: 525 flips per 20s walk at 6 m/s, 75% appended-band-internal); this removes the ~45% of sustained walking churn that was downgrade flips, and rim-spawned far chunks now ride their spawn LOD straight to eviction with zero rebuilds. Upgrades stay immediate, authored bands are byte-identical, the standard profile (server/MP/mobile) is untouched, and eviction semantics are unchanged. - Server job pool lane isolation (ledger 643 — the collider-less ground bounce loop): wedged Magic CDN collider generation made every
engine/glb-boundsjob burn its full 10s deadline (106/106 deadline-exceeded bodies in dump be7fc01e), the flood continuously leased every pool worker, and terrain chunk builds starved behind it (353 stuck-past-deadline-window builds, chunk-rescue firing 2553 times at feetY −690) — including the 5.0.15 remediation's own collider rebuild jobs, which rode the same starved pool and were defeated by it. Job definitions now declare a pool lane: terrain chunk/batch builds areworld-critical(dispatched ahead of the pool queue, ordered by priority so a chunk-rescue remediation rebuild at numericPriority 2 jumps even a backed-up terrain lane), glb-bounds isasset(the class is capped at maxWorkers−1 concurrent workers, so asset-bound floods can never lease every worker). The #7194 deadline race and lease watchdog bound each individual job; the lane cap bounds the class. - glb-bounds retry backoff: the fixed-cadence resubmit against a still-cooking asset was self-DoS (1,367 errors in one window). The job now probes Magic CDN URLs with the existing
x-magic-cdn-asyncprobe first — a cooking asset answers 202 in milliseconds instead of holding the ranged GET ~100s toward a CF 524 — and tags every HTTP-classified failure withhttpStatuson the job error payload. The bounds-prefetch feature schedules by failure class, per asset: exponential backoff from 5s doubling to a 5min cap (mirroring the terrain chunk retryBackoffTicks idiom), deferral to the slow cadence after 4 consecutive 202s (cold generation cooks for minutes — fast retries can never outwait it), and 401/403 parks immediately (auth failure ≠ still-cooking; hot retries can never succeed). 202 is terminal for a single job run — in-worker second-scale retries no longer burn the capped asset lane against a cooking asset. - Param-driven scripted-material rebuilds on live meshes are async + coalescing (ledger 641 — the Ascent spike storm). Scripts that read
ctx.params.xdirectly bake the value into the node graph as a shader constant, so animating the param fails the uniform fast path (applyScriptedMaterialParams→ false) and forces a material rebuild; rebuilding IN PLACE put an uncompiled material on a mesh that was already drawing — a synchronous NodeBuilder codegen + pipeline compile on the renderer worker (66-86ms spikes, 5-10 builds each, ~90% of samples in codegen) or a compile-hide of a visible mesh (the flicker rule). Now the rebuild compiles on a hidden stand-in mesh through the async compiler's newcompileReplacemententry (settle-callback contract: fires exactly once — resolved, failed, or dropped) and swaps onto the live mesh only when warm. At most one rebuild is in flight per mesh; param changes that arrive mid-compile coalesce into the entity's material intent, which settle re-reads (latest-wins — no queue growth under per-tick animation). Accepted trade, documented at the schedule site: the live mesh draws the old param value for the few frames the replacement takes. First-time builds, structural (authored) material replacements, and headless (no compiler installed) keep sync semantics; a structural change supersedes any in-flight param rebuild. New teaching diagnosticscripted-material-baked-params(perf-pointer DM rail, runtime log) fires once per ref at the third param-driven rebuild and points atctx.param()uniforms. - Tiling-surface filter exemption now covers
pixel-texture-*basenames. #7208 exempted thetexture-*tiling-surface class from pixel-filter inference so tiled grounds keep linear+mips — but the wave-7 assets that motivated the fix are scopeless top-level CDN textures wearing the vibe prefix (/cdn/pixel-texture-dark-cave-rock-wall.png,/cdn/pixel-texture-soft-blue-sky-day-gradient.png), whose basenames start withpixel-texture-, nottexture-— they still inferredfilter: "pixel"and still swam.isTilingSurfaceTextureIdnow also matches thepixel-vibe prefix; other kind prefixes stay positional (sprite-texture-artistremains a sprite), and authoredsprite.filterstill wins both ways. - Projection-reset re-lead gets the null-RTT fallback (ledger #637/#631 — the third clock-rebase site, the one #7207 missed): every resetProjection snapshot re-leads the client clock via
calculateJoinLeadTicks(rate, getRttMs()), and on 5.0.15 that lead was still RTT-blind when no round trip was measured. A wedged input lane is precisely the lane whose ack stream is starved (RTT null — no sample ever minted, or every sample aged out of the rolling window during the wedge), and the #7189 ingress-wedge self-heal cures a wedge by sending exactly this reset — so every heal re-landed the clock inside the server's consumption horizon, every frame arrived too_late again (no rescue by contract; tick-exact actions are never re-timed), and the heal re-poisoned the wedge it was sent to cure (Vacuo on 5.0.15: arrived=2785 tooLate=2785 tooFar=0, appliedPresent=0 for entire sessions, ingress_wedge_unhealed twice). Post-join resets now size an unmeasured-RTT re-lead forJOIN_LEAD_MAX_RTT_MS, same rule as the #7207 hard-adopt and suspend-resume rebases: erring too_far is recoverable (buffer empty-rebase + throttle grind). The FIRST reset — the join handshake the clock derives from — keeps the optimistic cold lead (the server's input buffer seeds its consumption floor from the client's first frame, and a worst-case lead would hand every join ~1s of surplus input latency), pinned by join-fast-forward tests. Audited every remainingcalculateJoinLeadTickscall site: the behind-resync and clock-runaway thresholds stay null-led on purpose (detectors must stay tight; the cuts they trigger carry the fallback themselves), and the lead-recovery slew target is exempt with its rationale documented in place (forward-only, measured against received ticks in the same frame of reference the join led from, and the too_late equilibrium is invisible to its detector anyway). - RTT samples now only ride
inputKind: "present"acks — the conservative-lead fallback (#7207/#7218) was structurally unreachable for wedged clients (ledger #647). The projection-reset re-lead falls back toJOIN_LEAD_MAX_RTT_MSonly whengetRttMs()is null, but RTT was never null for a wedged client:MultiplayerDebugTransport.recordAcks(engine/runtime/client/multiplayer-debug.ts) minted an RTT sample for ANY ack whose tick matched a sent input frame, and the server acks every client every tick — even when it applied nothing (inputKind: "absent"). WithsentInputTimeByTickretained 60s and the RTT window only 15s, a wedged client (every frame too_late → applied nothing →absent) still produced matchable acks, so the rolling window stayed populated with small measured RTTs forever andnetcode.ts:265always took the measured value. Every #7189 ingress-wedge heal re-led the clock RTT-blind-equivalent, landed below the server's consumption floor, went too_late again, and re-poisoned the wedge every 5min forever (Vacuo f3252536 on 5.0.15: 16 rehandshakes, appliedPresent bursts then decays to 0, tooFar≈0 — proving the conservative fallback was never applied). The fix gates the RTT sample oninputKind === "present"(the only ack that is a genuine round trip of an APPLIED frame);absent/decayedacks route tonoteAckWithoutSample(advance lastAckTick, mint no sample, drop the matched sent-tick). A sustained wedge now starves the present-ack stream, the rolling window prunes to null insideDEFAULT_WINDOW_MS, and the existing?? JOIN_LEAD_MAX_RTT_MSfallback finally engages — the re-led clock lands too_far (recoverable: buffer empty-rebase + throttle grind) instead of too_late, exactly the premise the netcode.ts:259 comment already assumed. Healthy clients get a present ack every tick, so their RTT stays measured and join-lead sizing is unchanged. Regression-pinned inmultiplayer-debug.test.ts(wedge → RTT prunes null; healthy → RTT stays measured). - Scripted materials: params are uniforms by construction (ledger 641 — one compile per script).
ctx.paramsreads of present, truthy numbers/booleans return TSL uniform nodes insidematerial(ctx)(scripted-material.ts createAutoParamsProxy); after each build a classification pass verifies every minted node landed in the returned node graph and re-runs the builder with raw values for any key the script inspected in untrappable ways (=== 2,typeof, plain-property assignment likem.ior = ctx.params.ior) — so a registered param uniform is, by construction, one the shader actually reads, and patching it is always sound. Plain-JS consumption (arithmetic, relational compares, string coercion) transparently yields the raw value via coercion traps on the node, keeping legacy math byte-exact and routing those keys to the rebuild path. Falsy reads stay raw sox || d/if (x)semantics never change (falsy patches rebuild). Newctx.constantsis the explicit raw escape hatch for structural params.applyScriptedMaterialParamsnow also: treats params the build never read as free no-ops (kills the permanent rebuild storm from stale spec keys), and rebuilds on string param changes (previously a silent no-op forever). This removes the Ascent-class hitch storm: animating actx.params.*-driven material was 5–10 NodeBuilder rebuilds (~70ms) every patch; it is now one uniform buffer write. Param VALUES never key the compile cache or the structural material signature. - Scripted-material shaders now precompile ahead of first appearance. A scripted material on a standalone primitive renders as a plain
THREE.Mesh, so three keys its pipeline by VALUE (material config + geometry attribute layout + the scene's lights/clipping/context) — and ledger 641 made scripted params uniforms, so param values never key the cache. That makes the (ref × geometry) pipeline warmable from the spec before any object wears it. New flow: the sim derives aMaterialWarmHintscomponent (replicate "never", forwardToRenderer "always") of every distinct (scripted ref × standard-primitive geometry) pair in the spec (tome/material-warm-hints.ts, written from applySpec beside the script libraries); the renderer's newscripted-material-warmer.tsbuilds each pair's warm mesh andcompileAsyncs it against the LIVE scene + camera during idle frames only — gated on the shared frame-idle verdict (post-boot-drain AND idle-now), one warm per frame, never competing with a real compile or load. Warm meshes are retained (never added to the scene, never disposed while warm) so three's per-RenderObjectusedTimesrefcount keeps the shared nodeBuilderState/pipeline cache entry alive until a real consumer arrives; when the real primitive appears its compile is a cache hit instead of the ~90ms TSL→WGSL codegen + pipeline stall (ledger #152). Edits re-warm (the material-scripts handler's changed-refs drive the warmer'sinvalidate); vanished targets drop and dispose. Pure best-effort, like the asset preload sweep: a stale target costs only idle work and a missing one falls to the existing compile-hidden async path. Instanced/skinned/morph consumers are out of scope — three keys those pipelines per-object (RenderObject adds object.uuid), so a separate warm mesh can't share them; they keep the async path. - Chronic server-behind banner (client presentation of an existing condition — zero server changes): a new client-side cadence monitor (
engine/runtime/client/server-behind-cadence.ts) watches the rate the authoritative tick advances against wall clock — every StateDelta is stamped with the server's current sim tick, so the ratio is the server's sim-clock rate, independent of delivery cadence (sparse AOI-quiet deltas still carry full tick advance) and blind to client-side lag. When cadence holds at ≤0.7× across a trailing 10s window on a provably-live socket (wire activity within 2.5s — heartbeats count; pure silence stays the reconnect/watchdog story), the kernel iframe shows a top-center "Server running behind…" pill (same visual family as kiln's "Reconnecting…" connection pill) and the host emits one[worker-browser-host]warn per episode edge through the iframe→parent console-forwarding lane to DD, carrying roomId + cadence ratio + observed/expected ticks (appId/variantId/roomMode ride the forwarding payload). Recovery requires the windowed ratio back at ≥0.9 (hysteresis — no flicker), and the window resets on every discontinuity: connection-phase edges, projection resets (reconnect resume, place travel, ingress-wedge heal), visibility flips, and deep tick regressions (the playout clock's rebase classification). Detection is fed by the netcode ingress system via the newNetcodeClientOptions.serverBehindCadenceseam, Ready-phase multiplayer only. - Restored the server's ECS retention prune lost in the codex-netcode merge (f142d013b — ledger 645): the ECS contract makes the host responsible for
world.prune(beforeTick)on a ~30s window, the client runtime prunes every tick, and the pre-merge server host pruned once per second — butcreateServerRuntimehad zero prune call sites, so on every long-lived room the change log grew append-only (an awake body appends a row EVERY tick; ~108k tick segments/hour),preRemoveRef[]/eventValue[]pinned object refs for every remove/event of the session, entity indices never recycled, and the despawn ring grew forever: heap growth → GC pauses → seconds-class "slow system" spikes attributed to arbitrary systems on long rooms. The restored call mirrors the old host exactly (30s window, once-per-second cadence — compaction is O(retained rows)) and runs at the end ofexecuteOneTick, after the replication phase has drained. Watermark safety verified in code: connections lagging beyond the window never need pruned rows — every lag class (grace-window reattach, suspend-resume, send failure, stale bucket, ingress-wedge re-handshake) funnels into NeedsReset →sendResetSnapshot, which rebuilds from live state (blob cache + dictionary snapshot), and an idle Streaming connection's gap contains no AOI-visible rows by construction (a visible row forces a send every egress tick while retained;drainDeltaadditionally clamps to retained rows and flagsneedsFullsync, pinned at the ECS level). No giant-first-prune hazard: engine version flips mint new containers, so no live room carries an unpruned backlog into this code, and with the prune active the backlog never exceeds window + 1s. Gauges added so dumps prove/disprove accumulation in one read:world.debugMemoryStats()(entities, entity capacity, change-log rows/ticks, oldest retained tick, despawn ring) on the server debug telemetry asruntime.ecs, and per-place physics occupancy (handles/bodies/colliders, rapier + mantle) asruntime.physics.places, both rendered in the multiplayer debug dump. Regression pins: a churning server runtime keeps history bounded by window+cadence and recycles entity indices (fails on the unpruned parent), and a real-pipeline reattach across a >30s pruned gap takes the reset-snapshot full sync, converges exactly, and books zero system errors. - Horde skinned-batch programs are nameable and the palette compute warms at mint (ledger #638, compile census rows 11/12): the batch render material and palette compute kernel used unnamed
storage()nodes, so WGSL named themNodeBuffer_<node.id>and every batch mint — every wave of an identical crowd, plus every capacity regrow — emitted structurally novel shader text that could never hit the program cache (Itero breadcrumbs: 933-1821ms hitches, ~10.7s frozen per wave-spawn window). The storage nodes now carry shape-stable names (hordeBakedFrames/hordePalette/hordeAnimParams/hordeInstanceMatrices/hordeFlash — the fxGpuRenderInstances law from #263), so byte-identical crowds emit byte-identical WGSL wave after wave and the program/pipeline caches hit. The palette compute kernel additionally sync-compiled inside the frame at the batch's first visiblerenderer.compute; a minted batch that isn't drawing yet now warms its kernel off-frame via a zero-work dispatch (packedCount 0, the fx-gpu spawnPassWarmed idiom), with the in-frame sync dispatch kept as the fallback when the batch draws before the warm fires. WGSL-identity and warm pins ride skinned-batch-wgsl-identity.test.ts. - Changed behavior-submitted storage authorization to scope player-owned keys by stable authenticated user id (
user/<userId>/...) instead of mutable player entity, client, or room/session identifiers. - Forwarded built-in
storage:*jobs in client-auth rooms continue to execute on the room server while preserving sender-scoped user authorization; cross-player aggregation (storage:list/storage:query) remains server-context (cron / lifecycle / custom jobs). - Updated Savi's storage, room, lifecycle, leaderboard, and API reference material to teach
objectApi.userIdas the durable save-key identity and the per-player-write → cron-rollup leaderboard convention. - Added regression coverage that forwarded
storage:setand server-contextstorage:get/listshare the sameuser/namespace. - WGSL sampler declarations now match every call-site (ledger #640, fork patch three-0.184.19-spawn.4.tgz). The fork's declaration pass gates the
${prop}_samplerbinding on filterability (isUnfilterableingetUniformFromNode/getUniforms, withsampler_comparisonadditionally requiringisSampleCompare), but thegenerateTextureGrad/generateTextureBias/generateTextureCompareemitters referenced${prop}_samplerunconditionally — one unfilterable texture (nearest+nearest, uint/sint data, unfilterable float, multisampled) whose graph carried.grad()/.bias()/.compare()producedunresolved value 'nodeUniformN_sampler', the device rejected the WHOLE shader module, and the fork silently skipped that draw forever (invisible terrain/water/shadow catchers). The emitters now carry the same gates as the declaration pass and fall back to an explicit-level load (plus astep()compare for the comparison case) — worst case a wrong-LOD/unfiltered sample, never an invalid module. The kernel's own raw-WGSLCompareLevelTextureNode(shadow atlas) gets the matching guard and defers to the builder's guarded compare path when the comparison sampler binding doesn't exist. Pinned byledger-640-sampler-decl.test.ts(the three repro shapes + filterable controls + both CompareLevelTextureNode sides), and a blanket undeclared-sampler scanner (wgslUndeclaredSamplerRefs) now runs on every shader the WGSL test harness builds. When a pipeline failure does quote an unresolved sampler (a fork regression or a new emitter), the renderer-pipeline-failed diagnostic now names the failing material's sampler-withheld texture (sampler-decl-culprit.ts). - Client-auth
spawnFxcalls now bypass write-through intent spawn deferral: replicated visual effects remain immediate and upload normally even when the behavior source has an earlier cross-writer intent awaiting a verdict, so shots reach observing clients without weakening deferral for durableapi.spawnstate. - Client room ingress now bounds unconsumed
StateDeltahistory, lets reset snapshots supersede queued deltas, discards state from closed sockets, and requests a current projection instead of replaying stale server history after a stall. - Sim-to-render recovery now replaces presentation backlog older than one second with an in-stream dictionary/entity reset and current-world snapshot, preventing historical movement and effects from playing forward after the renderer resumes.
- Default new-game humanoid locomotion now derives body yaw from the renderer-authoritative
aimYawSin/aimYawCoscamera basis instead of horizontal velocity, so strafing and backpedaling preserve look direction. - The First-Person Camera skill now teaches player-side look-facing, camera-only pitch, required derived aim axes, and removal of competing velocity-facing or
animated3DCharacter.faceMovementrotation. - Scripted textures (
tex-*.jsdrawn-in-code art) no longer hang forever when the bake worker fails to start. The bake transport's watchdog was armed only after a request reached a ready worker, so a worker that spawned but never posted{kind:"ready"}left every request parked in the pre-ready queue with no timeout, fault, or diagnostic. The watchdog is now armed at enqueue to cover startup and re-armed when the request reaches a ready worker so a slow cold start does not consume the bake window. Expiry tears down the worker and reports a budget fault instead of leaving a permanent loading placeholder. Synchronous worker construction/post failures now clear their pending watchdog, and late events from a replaced worker are ignored so they cannot tear down its healthy replacement. (This diagnoses the nested-worker startup failure; the off-thread path itself is unchanged.) - Rebuild the client-local
TextureScriptssource library when replicatedTomeSpec,DrawSprite, orDrawMaterialstate arrives. Projection resets recreate the replicatedtome/specentity and previously discarded its non-replicated texture library without reapplying an unchanged spec revision, leaving terrain tilesets and server-spawned scripted textures permanently missing in the renderer. - Relay baked
ctx.atlasmetadata from the renderer worker to the client runtime's existing sprite animation clock. Scripted sprite atlases previously rendered successfully but stayed on frame zero because their animation metadata existed only in the renderer worker. - Scripted textures (
scripts/tex-*.js/art-*.jsdrawn-in-code art) assigned at runtime now reach the renderer instead of failing "not found". The TextureScripts library was derived only from the static spec walk (sprite.texture/material.texture/terrain.tileseton spec objects) plus the fx-sink ensure hook, so a scripts/ texture set by a behavior was never collected.writeDrawSpriteandderiveMaterialDirectnow ensure runtime sprite refs and every texture-bearing material override (map, normal/PBR maps, alpha/matcap, and water maps), mirroring runtime scripted-material registration. The hook no-ops on non-script refs and unchanged source hashes. - Fixed client-auth query and hook snapshots eagerly guarding every top-level state field, which caused cross-player damage, knockback, and other forwarded effects to be rejected when unrelated target state changed in flight.
- Fixed attached
spawnFx()effects escaping before a taint-deferred projectile parent was promoted, which caused the server to reject the orphaned effect and made projectile visuals invisible to other players. - Latched stale render-stream recovery until its reset barrier is consumed, preventing backward client tick rebases from queuing a full reset and world snapshot every frame.
- Aged sim-to-render backlog with writer-local presentation ticks instead of authoritative ticks embedded in remote ECS deltas, preventing healthy non-host queues from being misclassified as infinitely stale and repeatedly reset.
- Track unresolved client-auth spawn previews explicitly so
spawnFx()only waits for an intent verdict when its source or parent is an actual deferred preview, rather than any client-realm entity. - Client-authoritative intent effects are now keyed only to the behavior invocation's causal refs. Stored timer, job, and event callbacks carry their registrar's refs, while unrelated later invocations no longer inherit an earlier cross-writer operation's deferral. Network latency therefore cannot keep unrelated projectiles and attached FX local-only without allowing a dropped premise to mint delayed side effects.
- Preserved ECS change-log append order in the renderer drain instead of sorting mixed server and client tick domains, so client-derived visual components cannot arrive before their remote entity spawn and be dropped.
- Added a client-only presentation policy that gives remote players and their active control targets one additional buffered transform tick without delaying the camera, local player, projectiles, or world entities.
- Retimed renderer deltas onto the client presentation clock so server-stamped ECS changes cannot make local player or camera snapshots appear stale.
- Fixed remote player freeze-and-jump motion when a client-auth transform upload misses one otherwise-current server batch.
- Singleplayer remote-player cleanup now preserves the local player's slash-qualified child entities. The per-tick sweep previously treated untagged children such as
player/<local-id>/balloon/bodyas stale remote players, repeatedly despawning game-authored attachments while their behavior recreated them and eventually overwhelming the render worker.
›migration notes
Player-owned storage now scopes to the stable account namespace user/${objectApi.userId}/.... objectApi.userId is the logged-in player's durable account id — it survives reconnects, rejoins, and travel between rooms. objectApi.id is the per-session entity id (a new one each join), so it is never a save key. Guard on userId first: it is undefined on non-player entities and before sign-in.
Replace older session/entity-scoped keys with the user namespace:
// Before — entity/client/session scoped, lost across joins
objectApi.job("storage:set", { key: `player/${objectApi.id}/save`, value: save });
objectApi.job("storage:get", { key: `player/${objectApi.id}/save` }, callback);
// After — stable account scoped
if (!objectApi.userId) return;
objectApi.job("storage:set", { key: `user/${objectApi.userId}/save`, value: save });
objectApi.job("storage:get", { key: `user/${objectApi.userId}/save` }, (result) => {
if (result.ok) applySave(result.data.value); // get returns result.data.value
});
The one rule
A player behavior (update, onInput, onInteract, …) is player-context: it may touch only its own user/${objectApi.userId}/… keys. Any other key or prefix (world/state, leaderboard, another player's key) is rejected with a teaching error and the storage job does not run. storage:list and storage:query are cross-player reads — they are server-context only and throw from a behavior.
Shared or cross-player data (leaderboards, world state, anything aggregating many players) is read/written from a cron job or lifecycle hook, which runs server-context and may touch any key. Build the shared value by rolling up the per-player user/<id>/… keys.
Patterns
Per-room save (only when the save is intentionally room-scoped) stays inside the player's namespace:
const key = `user/${objectApi.userId}/room/${objectApi.getRoomId()}/save`;
Server-side aggregated leaderboard — each player writes its own score row, a cron rolls them up. Convention: write { score } to user/${objectApi.userId}/score.
// Player behavior — write only your own row
if (!objectApi.userId) return;
objectApi.job("storage:set", { key: `user/${objectApi.userId}/score`, value: { score } }, () => {});
// scripts/cron/leaderboard.js — wired via engine.crons; server-context, may read every key
export async function cron(objectApi) {
const lock = await objectApi.awaitJob(objectApi.job("storage:lock", { key: "leaderboard/global" }));
if (!lock.ok) return; // awaitJob resolves a JobResult (never throws) — bail if the lock timed out/contended, don't touch the board without it
try {
const result = await objectApi.awaitJob(
objectApi.job("storage:query", {
prefix: "user/", // literal key prefix, not a glob
where: [{ field: "score", op: "gte", value: 0 }], // top-level fields only; keeps scored rows
sort: { field: "score", dir: "desc" }, // one top-level field, asc/desc
limit: 10, // default 50, clamped 1–200
})
);
if (!result.ok) return;
const board = result.data.items.map((e) => ({ playerId: e.key.split("/")[1], score: e.value.score }));
await objectApi.awaitJob(objectApi.job("storage:set", { key: "leaderboard/global", value: board }));
// Surface it to players by writing onto a replicated controller entity; behaviors read
// that replicated state rather than pulling the shared `leaderboard/global` key themselves.
objectApi.patchObjectState("leaderboard", { board });
} finally {
await objectApi.awaitJob(objectApi.job("storage:unlock", { key: "leaderboard/global" }));
}
}
Query constraints: storage:list/storage:query return result.data.items as [{ key, value }]; where/sort operate on top-level fields of the stored value (ops eq/neq/gt/gte/lt/lte/contains, ≤4 clauses); prefix is a literal key-prefix match; limit defaults to 50, clamped 1–200. Use storage:lock/storage:unlock to make a read-modify-write of a shared document atomic — awaitJob resolves a JobResult rather than throwing, so check lock.ok and bail before the critical section if it wasn't acquired, and always unlock in finally. Full patterns: the jobs-and-storage and leaderboard skills.