engine v5.0.15
Engine v5.0.15
June 12, 2026
A patch in the For Real line.
what's new
- Multiple moving shadow-casting lights now all keep their shadows — previously only one moving light at a time could.
- Fixed long lag-behind sessions endlessly rubber-banding — the game now does one clean catch-up resync instead.
- Fixed buttons and controls permanently dying after a slow game load — presses no longer "work for a split second, then snap back". If your game's UI broke when you updated past 5.0.7, this is that fix.
- Lights are now easy to select in god mode — the click target is the light itself, not its entire glow radius.
- Fixed a rare condition where the game stopped responding to your controls after a server restart until you reloaded.
- Fixed a bug where a world's ground and collision could fail to load (players falling or floating at spawn) when an asset server was slow — terrain now builds reliably even when assets are having a bad day.
- Worlds with freshly generated models load more reliably — slow first-time asset processing no longer makes physics and models fail to show up until a retry.
- Ground textures in 2D games no longer shimmer while the camera moves.
- If your world ever stops drawing, the engine now notices within seconds and tells Savi exactly what happened — that it's a graphics-driver problem a browser restart fixes, not a bug in your game — instead of leaving a silent black screen.
›technical notes
- ShadowAtlasScheduler's dirty render queue is now ordered oldest-rendered-first (the stale queue's existing age idiom; never-rendered faces lead, score breaks age ties) instead of score-first. A moving shadow-casting light re-dirties every face every frame, so under a per-frame face budget smaller than the standing dirty set the score-first order replayed the strongest light's first
budgetfaces forever — starving every other dirty light AND the strongest light's own remaining faces whenever its face count exceeded the budget (high tier: rendersPerFrame 4 < a point light's 6 faces, so no orbiting point light could ever complete acquisition andready/fade stayed pinned at 0 — the jure 1-of-3 field repro: three shadowed point lights, two on orbit/bob behaviors, only the static one ever cast). Age ordering round-robins the budget across all dirty faces, bounding per-face staleness at ceil(dirtyFaces / budget) frames: every moving light keeps a slightly-lagged shadow instead of one light keeping a live one. The budget cap itself is unchanged — fairness redistributes who gets the renders, not how many happen. - Client behind-server resync (ledger 631, dump 7797adb0 "Ark Caves"): a client whose local clock fell moderately behind the server's tick (under the suspend-resume cut's instant threshold, beyond what the lead-recovery slew was healing — Ark Caves sat ~40 ticks behind for hours) used to fire a mismatch correction on every compare (20,554/20,554 resims, ~81ms/s replay burn) because its input frames reached the server after their ticks were already simulated. Behind is now a resync condition, not a mismatch condition: (a) prediction compares are suppressed while the client is strictly behind the newest authoritative tick (a properly leading client keeps full drift detection — the gate is strictly client-behind-server), and (b) a fall-behind that sustains above 2× the join-style lead for 30 consecutive ticks with fresh authoritative arrivals triggers ONE full resync through the existing suspend-resume cut (authoritative-snapshot adopt + clock re-lead + input baseline reset + prediction recapture — never fabricated state, tick-exact input apply untouched). Flap-guarded: one resync per 15s; a re-trip inside the window is held and escalated loudly once.
- Clock rebases size their lead for the worst tolerated RTT when none is measured (ledger 637, the Vacuo 5.0.8 input-death regression). RTT samples ride input-frame acks, so a boot-time stall that blocks the server before any frame is acked reaches the hard-adopt rebase with RTT=null; the old RTT-blind lead (~8 ticks, no transit term) parked the client clock inside the server's consumption horizon for any real RTT above it. Every subsequent frame arrived
too_late— hard-dropped, actions acked-as-dropped, the client un-applying each predicted action (buttons "work a split second, then snap back") — and since no frame was ever accepted, no ack ever minted an RTT sample, so the next adopt re-rebased blind: permanent, self-sealing input death, invisible to the behind-server detector (the clock stays nominally ahead of the newest received snapshot while behind the horizon). Both rebase sites (hard-adopt and suspend-resume cut) now fall back toJOIN_LEAD_MAX_RTT_MSwhen RTT is unmeasured: overshooting strands the clock slightlytoo_far— the side with the buffer's empty-rebase rescue and the input-throttle grind — while undershooting has no rescue by contract. Field-confirmed on master before the fix (arrived=2262 tooLate=2261 appliedPresent=0, self-heal re-poisoning within 5 minutes). - God-mode lights now pick by a compact bulb-sized handle instead of effectively by their photometric range. A light has no raycastable geometry of its own — its pick surface is the editor visual, and while selected that visual spawned two range-radius torus rings (
range-flat/range-vertical, radius = min(distance, 16)) whose hits resolved upTomeParentto the light: every click inside the glow radius could land on a ring and re-select the light (the jure field report: "very difficult to work with", selection low-percentage, a huge mostly-empty sphere competing for picks). Three changes: (1) editor-visual GUIDE geometry (range rings, beam edges, emission wireframes — anything nested under…/__god_mode_visual/) is now non-pickable in both god-mode pick consumers (hover'spickable()and click-to-select's hit filters, the same idiom as the spline connector and the bed-footprint ribbon) — the visual ROOT (the marker) stays pickable, it is the light's click target; (2) the light marker is bulb-sized (octahedron radius 0.25 ≈ 0.5-unit click target, fixed, never scaled bydistance) instead of the 0.11/0.14 gem all markers shared; (3) the selection-outline subtree walk skips guide entities, so the silhouette cue traces the light + its marker — never the range-radius rings (the giant artifact-y outline in the screenshots). The range visualization itself is unchanged: rings still appear only while selected, at the real falloff radius, as hairline wireframes. Regression gate: editor-visual test pins every pickable entity under a light to ≤ 0.6 u bounds for distance 5/18/100. - Input-ingress health observability (ledger 631, dump 7797adb0 "Ark Caves"): the server's per-client input acceptance counters (
framesReceived,framesDroppedInvalid, the per-client truth row, the buffer's too_late/too_far tallies) were stats-only — visible in a debug dump, never logged — so a wholesale input-acceptance failure (server bookingabsentfor every tick of a connected, egress-receiving client for hours) ran past two real diagnoses. The netcode egress system now samples a per-client ingress-health monitor (engine/runtime/server/input-ingress-health.ts) every tick: (a) onetome.input.ingress_healthinfo line per client per ~30s carrying the window's arrival/apply/drop deltas and absent-tick ratio, and (b) a loudtome.input.ingress_wedgedwarn when ≥90% of a ~10s window's ticks bookabsentwhile the same socket delivered egress — the half-open wedge signature (ingress dead, egress alive). Suspended and grace-detached connections are never sampled; clients that never enqueued an input frame (singleplayer authorities, spectators) emit nothing. Observe-only: no engine behavior change. - Input-ingress self-heal (ledger 631, dump 7797adb0 "Ark Caves"): when a client's input ingress wedges half-open — every tick books an
absentack while the same socket keeps delivering egress (the ingress-health detector's condition) — the netcode server now forces ONE input-session re-handshake instead of letting the session stay input-dead until the room dies. The cure reuses existing handshake shapes only (no new wire message, no invented state):resetClienttears down the per-client input session (the tick buffer whose consumption floor gets poisoned when a fresh buffer seeds its baseline from a reconnecting client's stale-clock frames, plus pending acks/flow control), the Loaded welcome re-grants the session's live entities (the hello that arms client input sampling), and a dedicated reset snapshot (resetProjection) triggers the client's existing re-sync (handleProjectionReset: clock re-lead + input baseline reset). Flap-guarded: one re-handshake per client per 5 minutes; a second wedge inside the window logs a loudtome.input.ingress_wedge_unhealederror once and leaves the session for a human. Healthy clients can never trigger it (the detector requires a ≥90%-absent window with egress delivered, and suspended/grace-detached connections are never sampled). - Fixed the fleet chunk-build wedge (2026-06-12, ledger 634): a job whose fn never resolved (e.g. a glb-bounds fetch against a 524ing Magic CDN) leased its server job-pool worker forever — deadlines were only checked after the fn returned, so nothing errored and every job submitted afterwards (all terrain chunk builds) starved silently until the queue-side stuck window failed them en masse.
- Job deadlines are now a live race in the worker harness: a job that outlives its
deadlineMsis aborted (itsenv.signalfires) and fails withdeadline_exceededimmediately, freeing the worker.deadlineBehavior: "warn"keeps the legacy accept-late-results semantics. - The server pool gained a main-thread lease watchdog (60s cap): a worker whose job outlives the cap — sync-spinning or abort-deaf jobs the in-worker race can't reach — is terminated, the job fails loudly with
lease_expired, and a replacement spawns. - A worker that exits cleanly while running a job no longer silently strands that job; crash requeues are capped (2) before the job fails with
worker_crashinstead of crash-looping the pool. Async worker-spawn failures now drain the queue withpool_unavailableerrors instead of stranding jobs. - glb-bounds fetch attempts are hard-bounded at 4s (transient, so in-worker retries stay reachable) instead of holding a connection ~100s until Cloudflare 524s it.
- Observability: terrain chunk-build failure / chunk-rescue / collider-watchdog logs now route through the identity-stamped logger (appId/roomId/engineVersion instead of appid:unknown), and job workers inherit the bound log identity at spawn.
- Fixed the Magic CDN async probe skipping every server-side asset load (ledger 636, cold-asset 524s):
isMagicCdnUrlonly matched/cdn/paths, but the server-side AssetService rewrites/cdn/→/magic/(convertCdnPathForServer) before the loaders run — so container-side loads (model warming, collider hull fetches) never sentx-magic-cdn-asyncand held the connection open through a 3–6 minute cold cook, dying as proxy 524s./magic/paths now take the probe path too: cold assets answer 202 + Retry-After and the load retries on cooldown instead of riding a doomed connection. - 2D pixel-art filter grammar: tiling surface textures (
texture-*filename grammar) now keep linear+mips sampling instead of inheritingfilter: "pixel"from a-pixel-moodboard scope orpixel-name. The pixel inference sets min AND mag to NearestFilter, leaving the KTX2 mip chain unused — a ground tiled 48×48 sits several mip levels down, so every fractional camera offset re-picked texels and the floor scintillated with camera motion (4 sightings in wave-7 2D testing). The fix extends the existing plate exception (backdrop-*/strip-*/floor-*) to the tiling-surface class viaisTilingSurfaceTextureId, mirrored in both inference copies (renderer-asset-service + worker asset service). Sprites, tilesets, and character art stay nearest/crisp; authoredsprite.filterstill wins both ways. The general alternative (min=NearestMipmapLinear for the whole pixel class) was considered and deferred — mip averaging thins alpha on cutout sprites. - Sim-tick timing ring in the debug dump (ledger 631, Tucker): per-tick sim durations for the last ~45 ticks, client and server, so a dump answers "is the sim itself falling behind the tick budget" directly and crosses with Datadog app metrics to find slow systems. Client: the runtime books each MAIN simulation phase's ms into a fixed 45-slot ring (O(1) per tick, zero per-tick allocation; resim replay steps excluded — burst work already accounted by ResimulationStats), shipped on every perf rollup as
payload.simTimingand retained by the kiln parent for the dump capture. Server: each executed tick's ms ridesServerRuntimeTelemetrySnapshot.tickMsRingand the room's/admin/input-statsdump capture. The dump summary renders both as compact min/p50/p95/max lines plus the raw rings. Observe-only: no engine behavior change. - World-draw liveness sentinel (ledger 633, dump d96c25f3 "Spawnblock"): detects the silent GPU-process-reset class where a browser reset/eviction zeroes the vertex-pulled voxel bucket arenas / compute-cull storage WITHOUT a device-lost event — frames keep presenting at 2.5ms, every error rail stays empty, the world draws nothing. CPU info stats are structurally blind here (an indirect bucket counts 6 indices × 1 instance in
info.triangleswhether it draws a million quads or zero), so the trip predicate is CPU expectation vs GPU truth: the CPU-mirror chunk tables (bounds sphere + transform + live quad count — the upload SOURCE for the GPU buffers) walked against the camera frustum say how many quads SHOULD draw, while a ~20-byte async readback of the cull pass's GPU-written indirectinstanceCountsays how many DO. Expected ≥ 64 while drawn = 0, sustained 8s across consecutive 2.5s probes, fires ONE diagnostic (renderer-world-draw-stalled, allowlisted to Savi's getLogs + DM) carrying a trip-time forensic payload (bucket mesh flags, arena residency, GPU chunk-visible readback, last cull dispatch age, chunks installed, texture bytes). One-shot per session; a live probe re-arms it. Per-frame cost is O(1) — the expectation walk and readback ride the probe cadence, forensics run once at trip time. - Voxel-bucket containment weight (#7171 lineage, in the post-#7176 alarm path): when
handlePipelineResourceLimitExceededhides an object whose material is aterrain/voxel-bucket/*pipeline, the diagnostic now says what actually happened — hiding that one mesh hides the WHOLE voxel world (device-limit fallback, world-level impact, not a game-script bug) — instead of the generic "hid the object" line. - console.warn forwarding (iframe→parent, ledger 268 lane): the kernel client now forwards console.warn lines that start with the engine's own prefix markers (the
[renderer]/[worker-browser-host]family) assource: "console.warn", on their own 10/min budget so a warn storm can never starve error forwarding; creator/third-party warns never leave the page. The renderer host re-emits warn-class worker diagnostics (renderer-build-failed, the pipeline-guard rejections) on the page console as[renderer]warns so they ride the new lane, and the world-draw stall re-emits as console.error so the world-down class rides the existing error forwarding. kiln's parent listener accepts the new source and logs it to Datadog at WARN status (apps/kiln/lib/kernel-iframe-errors.ts).