Spawn
spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.1Connection2 weeksengine v5.0For Real4 weeksengine v4.6AtelierJune 1, 2026engine v4.5Surface TensionMay 22, 2026engine v4.4SolidMay 15, 2026engine v4.3GroovyMay 13, 2026engine v4.2ContinuumMay 9, 2026engine v4.1FoundationsMay 4, 2026engine v0.1GenesisApril 29, 2026

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

Connectionengine v5.12 weeksFor Realengine v5.04 weeksAtelierengine v4.6June 1, 2026Surface Tensionengine v4.5May 22, 2026Solidengine v4.4May 15, 2026Groovyengine v4.3May 13, 2026Continuumengine v4.2May 9, 2026Foundationsengine v4.1May 4, 2026Genesisengine v0.1April 29, 2026
← All posts
← All posts

engine v5.1.9

Engine v5.1.9

July 3, 2026

A patch in the Connection line.

what's new

  • ⚠️ If your world suddenly looks different after this update: your custom look script is running again. It was silently crashing on the last few engine versions (since around 5.1.5), so your world was rendering with no look applied at all. The crash is fixed and your authored look applies again — that new appearance is your own look script working. If it feels off (too dark, too bright, too saturated), ask Savi to retune the exposure — don't delete the look.
  • If a model ref in your world is malformed (a name the asset server can't parse), the logs now tell you once, clearly, which ref is broken and why — instead of retrying it silently in the background forever.
  • Behind-the-scenes: game-server logs now identify which world and engine version they came from, so problems in live games get traced and fixed faster. Nothing changes in your worlds.
  • Savi now judges a custom material's cost by the measured GPU number in every preview, calibrated against real games' materials — expensive shaders (especially layered fur and transparency) get priced honestly before they ship instead of being mistaken for free.
  • Wrong-shaped primitive keys no longer fail silently: writing topRadius where the engine wants radiusTop now warns with the correct spelling instead of quietly rendering your shape with default dimensions — and Savi's other dead-end errors (misplaced primitive dims, event handlers in one-shot scripts, mis-called primitive helpers) now say what to do instead of just what went wrong, so she fixes them in one step.
  • Behind-the-scenes: the engine now reports exactly where script runs spend their time, so slow run_script moments get found and fixed faster. Nothing changes in your worlds.
  • Worlds with lots of effects now render far more of them on the GPU: idle effects lend their GPU budget to the ones actually firing (and take it back when they fire again), instead of the first few effects permanently hogging it while everything else dropped to the low-quality fallback renderer.
  • Fixed rain and similar effects falling through flat terrain on the GPU path — drops now land and die exactly at the ground (splashes, rings, and hearts appear on the surface instead of underground).
  • Behind-the-scenes: effect re-fires stay correct and warm around edited effect art and slow texture loads. Nothing changes in your worlds.
  • Fixed a ~1s whole-game hitch (sometimes "all meshes reloading") when a trigger effect fired again after sitting idle for minutes — level-ups, kill ceremonies, and other occasional effects now keep their GPU resources warm and re-fire instantly.
  • Effects can now burst into multiple children on death: when(heart.dies, { count: 8 }) shatters every dying heart into 8 shards (up to 32 per death). Combine with probability to make only some deaths shatter — a shattering death always bursts the full count. The shards' spread, speed, and look stay yours: author them in init:/inherit: like any population.
  • Objects no longer silently vanish after joining: if a generated mesh gets lost on the way to your screen, the engine now notices within seconds and re-sends it automatically — and it leaves an honest log entry instead of nothing, so "the spec says it exists but I can't see it" stops happening (and stops looking like Savi lying about your world).
  • Savi now builds isometric and 2.5D sprite buildings from modular floor, wall, and roof pieces on the grid — with doors and windows as wall variants and roofs that hide when you walk inside — instead of painting each building as one flat image.
  • Fixed images and models with transparent edges (cutouts, sprites, foliage) showing solid black around them — transparency renders correctly again.
  • Material previews now show the real GPU cost of your material — the number that actually decides whether a shader is cheap — instead of only the CPU cost, which stayed misleadingly flat even for very expensive materials. Savi reads it with every preview, labeled as one point on the cost curve (your device, preview resolution), so expensive materials get caught before they ship.
  • Worlds with extremely dense decoration carpets no longer risk freezing on the first frame of weaker devices — the engine caps how many instances it builds at once (generously), tells Savi in the logs when it did, and the quality system keeps tuning from there.
  • Scattered decorations (grass, flowers, rocks) now stay out of buildings stamped as terrain structures — no more flowers poking through walls or grass carpeting a roof. Want plants inside a ruin on purpose? Set filter: { avoidStructures: false } on that scatter.
  • Savi's script edits now reach you while you're in the game: previously in singleplayer, once you'd built anything in-session, every fix Savi saved was silently ignored by your running game — it looked broken until you refreshed the page, while friends joining fresh got the working version. Edits and version restores now apply live, and if an edit ever is withheld, the game says so in the logs instead of staying silent.
  • Heavy building no longer stutters the world for players — when Savi and her wisps save in rapid bursts, the room now applies the newest state once instead of hitching on every save.
  • Fixed: one malformed field in your game's spec can no longer break ALL building — before, a single badly-shaped list (for example after asking for a full game wipe) silently blocked every change from reaching the game for minutes. Now the engine repairs what it can, keeps applying everything else, and tells Savi exactly which field was wrong so she can fix it in seconds.
  • Savi can now truthfully wait for the world to finish loading: api.getWorldResidency() reports when everything has actually streamed in on a player's machine (and how much is still pending), so start buttons, intros, and loading screens can gate on the real thing instead of timers or framerate guesses.
  • Behind-the-scenes: game tabs left open for hours no longer keep hammering the server with expired credentials — nothing changes in your worlds.
  • Behind-the-scenes: connection-health reporting got more accurate — nothing changes in your worlds.
  • One long stall no longer snowballs into a permanent slideshow: when your tab hiccups for over a second (backgrounding it, a big world update landing), the engine catches the renderer up with one full refresh — it used to mistake its own refresh for another stall and refresh forever (climbing memory, a hot GPU, an eventual crash on a world you were just standing in). Now one stall means one refresh, then the game goes back to normal streaming.
  • Repeated "nothing changed" updates stopped chewing the frame budget: per-tick scripts that re-apply the same particle or light settings every tick, and re-delivered identical terrain configs, now recognize "same content" and stay quiet — real changes flow exactly as before.
  • Fires that never burned out now burn out: seconds() with no argument now reads the current game clock in seconds instead of silently returning 0, so scripts measuring elapsed time with it (campfire fuel, cooldowns) work the way they always meant to.
  • Big terrain worlds stopped paying a server tax for standing still: the server no longer re-checks every terrain chunk's collider every tick, only the ones that actually changed — the biggest cause of "server running behind" slowdowns in large worlds is gone, and heavy terrain editing stays smooth because rebuild bursts only cost the chunks being rebuilt.
  • Game menus no longer trigger the browser's right-click/copy-paste menus, text-drag selection, or image drag-to-save — automatic now, no script guards needed. Want copyable text somewhere? Add select-text to that element; a deliberately draggable image just needs draggable="true".
  • Real WebAudio for your games: write playlists, crossfades, ducking, and custom mixes as plain WebAudio in your UI script's onMount — the engine hands you its live audio graph (audio.context, audio.bus("Music"), audio.buffer(ref)), your sounds respect every player's volume settings, and the engine cleans your graph up on every edit automatically.
  • Music can finally end: track-end transitions, playlists, and menu→gameplay music are now ~15 lines of vanilla WebAudio instead of workarounds around musicShift.
›technical notes
  • Carried warning from 5.1.8's three r185 re-vendor (#7343): games upgrading from ≤5.1.6 get their custom look scripts running again — the #661 getNodeType TSL crash silently killed look builds on the r184 engines (5.1.5/5.1.6), so those worlds rendered with no look applied. Authored grade/vignette/grain chains apply again on upgrade and the world's appearance can visibly change; the creator-facing warning rides this version's changelog and migration notes.
  • Bounds prefetch now classifies HTTP 400 from the Magic CDN as a malformed asset ref and parks it on the slow probe cadence immediately (like 401/403), with one loud log line naming the bad ref and the CDN's teaching error — instead of riding the 5-minute retry cap forever while the room lives (ledger 724: extensionless model refs burned 800–3,500 log events/day since Jun 19). 404 deliberately stays on the exponential lane: a missing asset can heal (generation completing, an asset created later), and the 202-defer path already covers pending generation; a 400 never heals without a spec edit.
  • Container stdout/stderr telemetry now carries identity tags (ledger #734). The game-server image's Datadog log tee (scripts/container-log-tee.mjs, wrapped around the entrypoint by scripts/container-entry.sh when DATADOG_API_KEY is present) now tags every shipped line with engineVersion / engineHash / variantId / roomId read from container start env (SPAWN_ENGINE_SEMVER / SPAWN_ENGINE_HASH / SPAWN_VARIANT_ID / SPAWN_ROOM_ID — placed there by cf-edge's startOptions.envVars, boot-immutable because they key the GAME_CONTAINER DO), mirroring the winston Http transport's ddtags order. Previously raw stdout lines (console.* bypassing winston, [job-worker] prints, Bun panics) shipped with appId:unknown and no identity beyond stage — ~95% of source:cf-container volume — so prod investigations joined attribution by hand (boot-line hash greps, body wildcards). appId keeps its unknown boot fallback on tee tags (it binds post-boot via the spec fetch); appId joins ride the parsed attributes of winston JSON lines. No key = direct exec, byte-identical to before.
  • Provenance fix: the tee (Dockerfile ENTRYPOINT switch + container-entry.sh + container-log-tee.mjs + tests) has been running in the deployed staging/prod images since ledger #442 but its source only lived on an unmerged image-build commit (cfe6b5622) — a rebuild of the image from master would have silently dropped it. The source now lives on master. Rides the container IMAGE, not the kernel bundle — takes effect at the next container-build.yml run + cf-edge deploy.
  • custom-materials skill: the cost curriculum now anchors on the real GPU number preview_object reports (the gauge added by #7636) instead of prose cost rules. Savi is taught to read the GPU line after every material create/edit, treat it as one point on the cost curve (phones pay steeper, worst for transparency and layered cutout), and calibrate against four measured real-game anchors — plain node math ≈1×, one fractal-noise tap ≈3×, a giant unrolled ALU shader only ≈16× but with a ~650ms first-use compile hitch, shell-layer cutout fur 60–111× (overdraw multiplies the whole shader by layer count). The two off-gauge axes (compile hitch, per-frame needsUpdate texture re-uploads) are named. Cost rules the gauge answers better were cut: the CPU-encode check loop, the MeshPhysicalNodeMaterial-is-expensive class tag (measured: clearcoat ≈ noise floor), and the glass hero-object rule. Anchors are one-rig measurements (M4 Max, real prod materials built through the production buildScriptedMaterial runtime), taught as exchange rates, not fleet truth.
  • Error-channel quality batch (ledger 723, from the Starfall triage): four teaching fixes on the paths where Savi/wisp mistakes either failed silently or named the ban without the road. (1) Unknown keys inside properties.primitive — previously accepted without a sound while the mesh regenerated with default dimensions — now warn (fail-soft, getLogs-visible) with a did-you-mean against the kind's schema keys (topRadius → radiusTop); key sets derive from PrimitiveSpecSchema, no hand list, and the lane never rejects because live specs already carry junk keys. (2) The spawn placement rejection for primitive dims left flat in properties (width, radiusTop, …) now names the destination: they belong inside properties.primitive. (3) The transactional run_script guards (api.on, api.runInTicks, api.runInSeconds, api.runSchedule, api.job, api.createJoint — the biggest guard class fleet-wide, 468 events/112 apps/7d) each teach the alternative in one line: handlers/timers/jobs live in behavior scripts. (4) builtin/primitives helpers — the only positional API on an otherwise object-shaped surface — validate their first argument and throw a teaching error naming the positional form when a props object lands in the api slot, retiring the unreadable minified Q.uniqueId is not a function class.
  • Per-exec structured log tome.exec.settled (ledger 714 r2 — the exec breakdown black hole): the host already measured snapshotMs / queueWaitMs / workerExecMs / rebuildMs / mergeMs / snapshotBytes per exec but discarded everything except durationMs into an unread resource — exec-lane stalls were unattributable in Datadog. Every settle now emits one info line with the full breakdown (plus ok/terminated, scriptHash, totalMs, simThreadMs), volume-gated: failures/terminations, sim-thread stalls (snapshot+merge > 8ms), and slow execs (total > 250ms) always log; healthy fast execs sample 1-in-20 per room, marked sampled: true. Also fixes queueWaitMs, which mixed clock bases (epoch enqueue stamp vs performance.now dispatch stamp) and always clamped to 0.
  • The GPU fx particle arena now has a budget/eviction policy (ledger #707). It used to be first-come-forever: populations claimed segments at create time and never released them while re-fireable, so a full arena silently rejected every later effect to the CPU fallback renderer for the rest of the session (12 of 16 zoo fx populations always ran the CPU path — the "mega trash" render quality was the fallback renderer, not GPU defects). Under allocation pressure (particle segments, program windows, or death-ring windows) the backend now PARKS the least-recently-spawning dormant effects — every population readback-confirmed dead, no death records pending, spawn-idle past FX_GPU_ARENA_EVICT_IDLE_FRAMES (same ~5 s cadence as render.ts's warm-park tier, so a parked effect's batch families are already warm-parked with material + texture kept) — releasing their arena allocations while keeping CPU spawn state (elapsed, burst timers, rng, persistent flags) intact. Active populations always beat parked ones; hot tenants are never evicted.
  • Parking is not a kill: a parked effect re-arms in place the moment any population wants to spawn again (burst due, persistent count pending, rate > 0) — fresh segments (possibly parking a now-colder effect in turn, LRU), program re-upload, coupling re-wired, and resetPopulationRuntime on every reclaimed range (#7603's corpse rule: stale alive flags from the previous tenant must be cleared before stepping). Timelines never restart and no spurious volleys fire; a frozen-at-due burst fires exactly one volley at re-arm.
  • Eviction and forced fallback are loud instead of silent: console.warn per event plus cumulative renderer counters on GpuFxStats.arena (evictions, exhaustionFallbacks, parkedEffects, residentEffects, freeParticles).
  • The WGSL coupling gate (scripts/verify-fx-gpu-coupling) gains an arena-pressure-eviction scenario: more population demand than arena capacity on a real WebGPU device — a hot whole-arena tenant refuses a competing create loudly, a dormant one is evicted, the death→child coupling chain runs corpse-free in the reclaimed ranges, and the parked tenant re-arms once pressure lifts.
  • Fixed GPU-path floor:"die"/floor:"stick" going permanently inert over heightmap terrain (ledger #711 — drops fall through the pad, die by lifetime 1–10 m underground in a ragged waterline, and every when(dies) child spawns occluded below ground, visually identical to broken coupling). Root cause: the fx terrain mirror's update-range overflow fallback (fx-gpu/terrain-heights.ts markDirty) cleared the pending updateRanges to enter three's "empty ranges ⇒ full upload" state, but the very next addUpdateRange in the same flush window re-entered range mode — so every cleared range (plus the triggering call's own) uploaded NOTHING. Any single sync wave touching more than MAX_PENDING_UPDATE_RANGES (256) chunks — a join burst, teleport, or LOD flip, with the nearest (pad) chunks synced first — left its first 257 chunks' chunk-table slots and height grids stale on the GPU forever while the CPU-side mirror map recorded them as resident, so sampleGroundHeightGpu read them as invalid and ground contact never fired. The CPU fx path samples the records directly and was unaffected. The fallback now collapses the pending ranges into ONE whole-buffer range instead of the resurrectable empty list.
  • scripts/verify-fx-gpu-coupling (real-WebGPU harness) grew the floor-contact-over-real-terrain coverage the defect hid behind (the existing floor scenarios used a synthetic sampler that bypassed the mirror entirely): a 3-phase standalone sampler probe over baked flat-generator chunk records (initial upload / post-first-dispatch late arrival / 300-chunk single-frame overflow wave), plus a zoo-neon-rain scenario driving the REAL mirror with terrain streaming in after the effect's first GPU frames — asserting drop deaths cluster AT ground height ± epsilon (death-ring readback), no live drop survives below the floor, and coupled children spawn at the waterline. Pre-fix these reproduced the live capture exactly (deaths at y≈−10.8, children meanY≈−6.3, 257 of 300 wave chunks invalid — the precise victim count of the range-clear). A vitest structural pin covers the CPU-side contract: after an overflow wave, every synced chunk's staging region must remain covered by the pending update ranges.
  • Hardened two edges in the fx warm-park tier (ledger #710, follow-up to #7607): (1) re-bake while parked — a parked family's bound texture id now carries a bind-time version stamp (provider getTextureVersion, new optional member on SpriteAssetProvider.service) that is re-checked at park lookup; a scripted re-bake/content swap while parked bumps the id's version and the old texels retire → dispose after the service's grace window, so a stale parked entry is now an honest MISS (dispose + rebuild through the async compile queue against the re-baked texture) instead of a warm hit serving pre-bake, soon-destroyed texels. (2) pending packs at park time — a family whose texture/pack never materialized (textureApplied false, 0 transferred bytes) is no longer admitted to the park: it disposed at the idle threshold before #7607 and does again now. Parking it claimed a residency slot (evicting real warm families oldest-first under FX_GPU_WARM_PARK_MAX_FAMILIES) while holding a 1×1 placeholder that could never materialize (no sync poll runs while parked), and its re-fire paid the texture round trip regardless.
  • GPU fx batch families that idle past FX_GPU_BATCH_IDLE_DISPOSE_FRAMES (~5s) are now PARKED warm — mesh hidden, material and texture retain kept — instead of disposed, bounded by a sized warm-park budget (FX_GPU_WARM_PARK_TEXTURE_BYTES = 16 MiB summed texture footprint, FX_GPU_WARM_PARK_MAX_FAMILIES = 64; oldest parked evicted first past either line). Disposal released the last same-WGSL material, so three's refcounted nodeBuilder/program/pipeline cache entries died with it, and the texture retain release let the 128 MB keep-alive budget evict the effect's sprite between fires — a trigger effect firing minutes apart (ledger #709: Eternal Blade's level-up on 5.1.8) re-paid the full cold path on the render worker every fire: TSL graph rebuild + WGSL codegen (synchronous inside compileAsync — #7343's async machinery defers the pipeline, not the node build), driver pipeline re-creation, a compile-hidden round trip, and a KTX2 re-fetch + re-transcode (off the render worker on KTX2Loader's sub-worker pool, but the effect fired white until it landed). The per-fire hitch was big enough to backlog the render channel into a stream reset — the creator-visible "whole game freezes and all my meshes reload."
  • A parked family re-firing into the same render window (the arena's LIFO segment-slot stack + first-fit range allocator reproduce it for a repeating effect) is a bind-identical warm hit: unhide the mesh, zero rebuilds, zero compiles, zero fetches. A re-fire into a moved/grown window still rebuilds honestly and rides the async compile queue, exactly as before.
  • spawn.when gains a count knob (ledger #703): when(parent.dies, { count: N, probability? }) spawns N children per triggering parent death (integer 1–32, FX_MAX_COUPLED_SPAWN_COUNT; validated with a loud FxProgramError beyond that — bigger asks belong to rate/burst populations). Probability stays a per-death filter — one roll per death, a triggering death always bursts the full count — and each child evaluates init/inherit against the same parent record with its own spawn randoms, so the shatter's shape lives in creator script math, not an engine preset. when(parent.dies, 0.5) (number arg) is unchanged.
  • CPU backend (spawnCoupled) is the reference oracle: per-death probability roll, then a count-deep inner spawn loop, still capped by maxParticles.
  • GPU backend: coupled over-dispatch is now perFrameDeathRecords × count, bounded by max(perFrameDeathRecords, childSegmentCapacity) — count:1 dispatch is bit-identical to before, and an unbounded count × death-ring product can no longer crowd later populations out of the frame's spawn map. The bound is a documented GPU-path capacity trade, not free: lossless at probability 1, but at probability < 1 under a single-frame death spike it truncates record coverage below what the CPU oracle delivers (e.g. 512 deaths × count:4 × probability:0.5 into a 1024-slot child covers records 0–255 only — ~512 children where the CPU delivers ~1024 inside the author's maxParticles); the GPU still delivers at least the bound's floor. New CoupledCount population-table field; the spawn pass maps coupled thread n to death record n / count (same parent window + probability draw per record, per-child variance via the localIndex spawn-RNG salt). Spawn-map truncation past the arena's per-frame thread budget now warns once instead of silently dropping later populations' spawns.
  • Gate extended and run: scripts/verify-fx-gpu-coupling grew a count-shatter-floor-coupling scenario on a real WebGPU device — a one-shot 64-heart volley floor-dies and must yield EXACTLY 64×5 children at the contact plane (no corpses, finite positions), with a probability-0.6/count-4 sibling arriving only in full 4-bursts. CPU-side zoo scorecard pins the clip-class semantics (a heart bursting into N shards) on the CPU oracle.
  • Ghost-mesh divergence (ledger #713): script-spawned objects existed in the spec and on the server but rendered nothing on a client, survived reload, and threw zero errors. Three fixes, one class:
  • Bespoke geometry self-heal (the real fix). A draw/mesh pointer (bespoke/<sig>) and its geometry value travel the render channel independently with no arrival guarantee; once the geometry op was lost under join pressure, the signature-equals gate (bespoke-geometry.ts) suppressed every later re-derivation of identical content — the renderer waited forever. The renderer now tracks unresolved pointers (three/meshes/geometry-wait.ts) and reports stalls up the existing relay (render worker → main → runtime worker, render.bespokeGeometryMiss); ecs-sync retransmits the LIVE draw/mesh + geometry values straight through the writer — a transport retransmit, no ECS write, so the equals gate never sees it. Wait-on-missing is now backed by guaranteed eventual arrival.
  • Loudness sentinel. The silent wait-forever in reconcileVisual (if (!geometryValue) return) now warns once per entity+signature after ~2s ("bespoke geometry never arrived: entity X sig Y"), raises the new log-only bespoke-geometry-stalled engine diagnostic (getLogs-visible, page-console forwarded for observability, running counters in the data), and re-reports on a 5s cadence to drive the re-forward until resolved. The mesh handler's store/wait sweeps now run on op-less frames too — the sentinel exists precisely for the op that never comes.
  • Join-blob same-tick cursor. EntitySnapshotBlobCache invalidation used a once-per-tick rangeSince(lastScanTick) scan (strictly-newer-than-tick): a replicated write landing later in the SAME tick as the scan never folded into the entry's dirtiness, so the cached join create-bundle stayed stale forever for that entity id (stale on every future join; survives reload, heals on new-id respawn — the same observed shape server-side). The scan is now a prune-tracked row cursor walked on every request with boolean per-entry staleness — same-tick ordering is exact by construction (every build happens with the cursor at the log's end), with prune-provability via newestPrunedTick().
  • 3d-sprites skill grows a "Buildings — Pieces on the Grid" section: sprite buildings are assembled from a modular piece family (floor/wall/roof at one module size, walls upright on cell edges with billboard: "none" + static physics, doors/windows as wall-piece variants, roof pieces tagged and hidden while a player stands in the footprint). This is the interim authoring pattern until the engine's native wall/roof tile layers land (ledger 730); the skill's phrasing survives that swap. Field-driven: a creator fought 3 days while Savi painted each building as a single sprite (dump 713aed61), and converged only after hand-writing this exact modular blueprint.
  • Suppressor tightening from the #7478 seam-check, verified against source: the Register now names the real cost of habit-copied 2D flags (getSpriteBatchKey returns null for any ySort sprite in ANY mode — a piece town becomes hundreds of standalone meshes, the observed 543–610ms frame spikes); 2d-mode's ySort bullet carries the same spend-it-only-on-interleaving caveat; Depth and Cutout teaches that script-baked textures (scripts/tex-*.js) are never pixel-inferred (isPixelInferredTextureId is a pure function of the texture id), so they opt into cutout: true explicitly.
  • Routing, on both surfaces (grouped skills' own descriptions are invisible in the manifest — the #7478 dig fact): the world-building group line in cf-studio-chat's SKILL_GROUPS now disambiguates mesh-built vs sprite-piece buildings, structures.md gets a body-level redirect + description update, and the ungrouped 3d-sprites hook gains "modular sprite buildings". 2d-mode's Isometric section gains the one-line piece discipline (band-sorted: floors/roofs batch, only walls ySort).
  • Backend MSAA is the default renderer anti-aliasing resolution again (DEFAULT_RENDERER_ANTI_ALIASING_MODE = "msaa"), restoring 5.1.6's msaaEnabled expression (baseline.boot.msaa && cuts.msaa !== false && !retinaDisplay) at default settings — desktop and tablet boot multisampled, phones keep their class msaa: false floor, Retina DPR-2 suppression stands. The SMAA default introduced in #7363 left every production session single-sample (renderer init never passes antiAliasingMode, so the "msaa" arm was unreachable), which silently disabled alpha-to-coverage engine-wide: creator cutout content rendered its transparent padding as solid black (first casualty: resx's spine rig).
  • SMAA and TAAU stay built and explicitly selectable via SessionParametersInput.antiAliasingMode — either post mode still disables backend MSAA (mutually exclusive by construction). A default-mode redesign lands in a separate controlled, well-tested AA fix PR.
  • The adaptive-quality governor's msaa-off rung is meaningfully reachable again: a landing's metered msaa cut boots single-sample per-device (it was moot while the default force-disabled MSAA everywhere). #7363's shadow-map changes are untouched.
  • preview_object's perf line now reports real per-frame GPU cost alongside CPU encode (ledger g-32 — scripted-material runtime cost attribution). The old line measured only cpuFrameMs, which is FLAT (~0.03ms) across a corpus whose real GPU cost spans 110× (fuzz shell 17.1ms vs noise-floor 0.15ms) — Savi shipped 17ms/frame materials believing them free. The fix wires the vendored three fork's existing GPU timestamps (backend.trackTimestamp + resolveTimestampsAsync, envelope semantics pinned by gpu-timestamp-frame-envelope.test.ts) into the preview rig: a new object-preview-gpu.ts sampler acquires the validated timestamp gateway (applyGpuTimingState — the same Metal-deny-safe path the inspector uses), runs a paced ~250ms busy warmup before the measured window (below that, Apple-silicon DVFS inflates sub-ms reads ~3×; warmup bursts await queue.onSubmittedWorkDone so live frames interleave instead of freezing), then samples a separate synchronous render leg (the CPU-timed leg's per-frame info.reset() would collide three's frameCalls-keyed query uids) and resolves per-uid pass durations — immune to the inspector's concurrent per-frame resolves. The perf line labels the number honestly: measured on this device at the preview's tile resolution, one point on the cost curve. Without a usable timestamp-query feature the line says GPU: unavailable (no timestamp-query) — never a fake number. The renderer worker re-asserts the inspector's requested GPU-timing state after every preview.
  • Added SCATTER_BUILD_INSTANCE_BUDGETS (ledger #664, the #191 lane) to src/engine/renderer/perf-static-data.ts: a static per-tier instance ceiling (200k desktop / 100k tablet / 50k phone) applied at decoration BUILD time in src/engine/renderer/three/decorations.ts, protecting the first frame from spec-authored counts the quality ladder can never measure (the build wedges the renderer worker before the governor gets a sample).
  • Under-budget builds are byte-identical (the allocator receives exactly the ladder budget); the ceiling binds only when the would-build count exceeds it, and deeper ladder rungs keep tuning below it.
  • When the clamp binds, a creator-legible line lands on the getLogs rail via the new log-only decoration-budget-clamped diagnostic code ("Terrain decorations clamped 500000→200000 instances (device budget); the quality ladder tunes from here.") — no DM, same device-local class as adaptive-quality-step.
  • Scatter bed placement now excludes the XZ footprints of voxel structure terrain marks (ledger #664 half B, the #191 through-wall lane): new resolveVoxelStructureFootprints in src/engine/features/terrain/voxel/marks.ts resolves the exact cell extents the structure builder claims (template bounds / resolveStructureBounds) to world-space AABBs — pure spec math, revision-cached, identical on both realms — and resolveScatterChildren rejects points inside a footprint plus the standard 1 m mark buffer, same as the existing river/pond/road exclusion.
  • Applies to both shape-bounds and painted (field-bounds) beds; measured worst case (500 points × 32 structures, all-miss) is ~11–17 µs per bed resolve, so the check defaults on.
  • Per-bed opt-out for intentional placements (overgrown ruins, props indoors): filter: { avoidStructures: false } on the scatter spec.
  • Out of scope, unchanged: GLB/model-object buildings and 3d-rooms terrain have no footprint declaration yet (the classification design the #7510 note flagged), and the GPU decoration-carpet path samples heightmap only.
  • Singleplayer live-edit divergence (ledger #718): an edited behavior script never took effect on an already-connected client — every Savi fix read as "still broken" to the creator until a full page refresh, while fresh joins got the new version. The disease is #7613's shape in the spec-push lane: applyWorkerSpecPush's staleness gate compared the pushed (server-minted) revision against the client's own counter — but revisions are minted independently per realm. The singleplayer authority bumps its copy on every LOCAL spec write (god-mode edits, behavior spec writes, committed run_scripts — the glued tome/spec-update), while the server's only bumps on foreign kiln applies (its own persists stamp dbVersion without a bump, and #7619's poke coalescing widens the gap further). Once the client counter ran ahead, every tome.spec.push — Savi's script edits AND revert pushes (ledger #296) — was silently dropped until refresh re-seeded the counter from the join snapshot.
  • The fix: gate on the kiln dbVersion, the one counter both realms share. tome.spec.push now carries the dbVersion the pushed spec was applied from (room-runtime → notifier → wire); the client applies when it's strictly newer than its own baseline (equal admits replace: true — the kiln-rejected-mutations rebuild demand, mirroring room-runtime's own guard, ledger #326). A dbVersion-bearing push over a client with NO baseline (degraded partial-hydration joins) adopts the kiln truth instead of dropping a foreign save by drifted counters; only pushes with no dbVersion at all fall back to the old revision gate. The stored revision always moves forward (max(pushed, live + 1)) so spec-sync can never skip an admitted push whose server-minted number collides with one the client already applied. resetTomeWorld now preserves the dbVersion baseline like every other TomeSpec writer — an in-game reset no longer blinds the gate.
  • Loudness sentinel. A suppressed push whose content hash differs from the live spec logs a [spec-push] dropped a stale spec push carrying different content warning (page-console/getLogs visible) instead of nothing; the healed lane (dbVersion admitting what the revision comparison would have dropped) logs one info line so field logs show the save arriving.
  • Spec-apply coalescing in the container's poke lane (ledger 714, fix half a): ContainerRuntimeProxy.updateTomeSpec now runs at most ONE apply per room at a time. A poke landing while an apply is in flight (or while the trailing window is open) answers 202 { ok, applied: false, coalesced: true } immediately and merges into one trailing fetch-latest apply, dispatched TOME_UPDATE_COALESCE_DEBOUNCE_MS (default 300ms, env-tunable) after the in-flight apply settles. Replace pokes OR-merge (a coalesced replace stays a replace — ledger #326's divergence heal), and the latest sdkConfig rides the trailing apply.
  • Collapsing is legal because applyTomeSpec always fetches the LATEST spec and the room's stale-echo guard (dbVersion <= existing → no-op) discards intermediate versions anyway: a 5-poke wisp burst that used to cost up to 5 × ~40ms synchronous applySpec hits on the sim tick thread now costs at most 2. Version monotonicity is the pre-existing guard, unchanged.
  • Provably invisible to the build lane: Savi/wisp save acks come from kiln's CAS write (save_game_spec_atomic in cf-studio-chat's save path), which completes before the poke is even sent; every consumer of the room /update response (kiln fanout, init-kernel) keys on HTTP status alone and drops the applied/revision body fields. The exec lane (run_script on the sim thread) is deliberately untouched — that is ledger 376's architectural thread.
  • Array-shape coherence for authored spec fields (ledger 715 — "Final Abyss" wipe, 4.5 min of wholesale apply failure): Savi generalized the API's record convention ({key: null} deletes, {key: patch} merges) to array fields, and both write paths stored plain objects where arrays belong — definePlace stored objects: {} verbatim, and patchEngine({crons: {0: null}}) blind-replaced the crons array with {"0":null}. Every subsequent applySpec then died with an unattributable TypeError: {} is not iterable (compiler.ts crons iteration, spec-assets.ts place.objects iteration). Two layers, matching write coherence with read fail-soft:
    • Write side: mergeSpecPatch now refuses ALL keyed-object patches on array fields — both the index form ({0: null}) and the stable-key form ({"scripts/a.js": null}) leave the array intact and emit a warning teaching the whole-array replacement form ({ field: [...] }). Array indices are not stable keys (sequential single-deletes would silently hit the wrong element after compaction), and every other keyed-map-onto-array surface in the API resolves by stable identity, so no positional surface is introduced. definePlace normalizes keyed-map/{} objects defs to arrays (ids from keys, updatePlace's convention — a def is a one-shot value, not a patch) and drops scalars with a warning; the recorded mutation carries the normalized def.
    • Read side: sanitizeAuthoredSpec repairs the poison class before anything walks the spec — objects, places.*.objects, engine.crons, engine.behaviors holding plain objects recover their entries (Object.values, keyed-null deletions dropped) with a loud getLogs+DM report naming each field; scalars drop the field instead of the apply. Belt-and-braces guards at the direct-call surfaces (compileSpec's cron iterations report engine.crons via onCompileError; collectSpecAssetEntries tolerates poisoned place.objects, scatter templates, decoration items, and null place entries — it's called from browser-host on raw specs).
  • New behavior API api.getWorldResidency(): { resident, pending } (ledger #717) — the renderer's own world-loaded verdict, finally readable from scripts. resident is a level, not a latch: it flips true only after every known real asset load (warm prefetches subtracted), terrain chunk install, and async material compile (queued roots, carry-over, compile-hidden objects, the router's deferred scene sweep) has drained and stayed quiet for RESIDENT_QUIET_FRAMES (90 consecutive renderer frames, ~1.5s at 60fps — asset floods arrive in waves, and an instantaneous flag would read true inside every gap); it drops the same frame new streaming starts (place transitions included). pending counts asset loads + terrain chunk installs for progress UI (compile tail work gates the flag but is not counted).
  • Residency is renderer-side truth on the machine asking: each client answers for itself. Server execution always returns { resident: true, pending: 0 } by documented convention (no renderer, nothing pending — mode:"both" scripts stay branch-free). Client answers are conservative-false until the renderer's first publish, and the renderer publishes nothing until its first authoritative ops ingest — a pre-content idle renderer can never report a world it knows nothing about as resident.
  • Plumbing: the verdict is fused per frame in the renderer worker from O(1) counters (new pendingModelCount on the asset service beside pendingTextureCount; the warmer's realPendingLoads now reads those counters instead of the allocating manifest-walking progress shape; new AsyncSceneMaterialCompiler.hasPendingWork() backed by a pending-object counter; new MaterialCompilationOpRouter.hasDeferredSweep(); a terrain install gauge written by the terrain handler's collect) and posted change-only up the existing renderer→main→runtime relay (resident edges immediately, pending-count changes on a 250ms throttle) into a module-level sim-side seam (tome/world-residency.ts, the preloadAsset determinism pattern — zero ECS-world trace on either side).
  • Auth-failure (1008 "unauthorized") WebSocket closes now count against a separate 3-close budget in ClientRoomRuntime (ledger #716): each 1008 is still retried with a URL refresh (getUrl), so a host that can mint a fresh token recovers, but a client stuck on an expired JWT stops after 3 closes and surfaces the existing terminal connection state (onConnectionFailed / onRetriesExhausted) instead of hammering verify every ~1.3s forever.
  • Retry budgets no longer reset on socket open — the server accepts the upgrade BEFORE token verify (network-worker mounts the room pre-auth), so an open proved nothing and made every failing cycle "attempt #1". Budgets now reset on the first server message of a socket (the server only sends to attached, post-verify connections: SessionWelcome immediately, heartbeats every 2s), which is the authenticated open.
  • Recovery signals (visibility/online/pageshow → requestImmediateReconnect) re-arm the auth budget along with the general one: a returning user reconnects immediately, with the fresh token their host can now supply.
  • WebSocket reconnect-success telemetry (recordWebSocketConnected) now fires on the first server message — the authenticated open — instead of the pre-auth socket open (review follow-up to #7626): in an expired-token 1008 loop every retry reaches onOpen before failing verify, so the old call site scored those retries as successes and closed their downtime samples, hiding exactly the loops ledger #716 is about. Perf rollups now report auth-failure loops as failures/abandonment, never false successes.
  • De-armed the stream-reset death spiral (ledger #735, the GPU-death root): ecs-sync collapses a >1s render backlog into one stream reset + full-world snapshot, but the reset barrier cleared on the tiny reset control frame while the snapshot's own decode+apply ran past the 1s threshold in chunk-heavy worlds — frames piling up during the apply armed the next snapshot from inside the current one, snapshot-per-second forever from a single stall (tab-hide, spec push): climbing memory, hot GPU, eventual crash. RenderChannelWriter.hasUnconsumedSnapshot() now tracks every world snapshot until the reader has drained the queue back to empty (consumer turn counters: the reader stamps a drain turn per drainOps entry, the render worker calls markApplied() after its op handlers run); while it holds, the stale trigger stands down to a 30× dead-reader bound (UNCONSUMED_SNAPSHOT_STALE_GRACE), so a hidden/wedged tab still collapses, bounded. Invariant pinned by test: one >1s stall → exactly ONE recovery snapshot → normal streaming resumes → the trigger re-arms for genuinely new stalls. Collateral deslop: the discard-only replacePendingFramesAfterStreamReset dies — sim-side tail rewrites carry their own in-stream reset row, closing a ghost window where entities despawned between rewrites survived a superseding snapshot.
  • Identical-content gates on the same flood paths (diet, not cure): particles/emitter and draw/light components gain deep equals at the settled write-site gate — dotted property writes rebuild the whole nested spec per call, so per-tick same-content writers (a campfire's ember spray, a lamp's glow) pushed identical values through replication and the renderer at 30Hz past the shallow default. And terrain/place-render-config now uses its configHash at setTerrainPlaceRenderConfig: a re-delivered byte-identical config returns no chunks (nothing enters the install queue, no slot re-binds) instead of re-processing every terrain chunk of the place. Content that genuinely changes per tick still flows exactly as before.
  • seconds() called with no argument (or a non-finite one) now returns the current game clock in seconds (getTick() / tickRate) instead of silently returning 0 — scripts read the name as a clock, so every duration computed from it froze (eternal campfires, cooldowns that never advanced). seconds(n) keeps seconds→ticks converter semantics exactly for finite args, both pinned by tests. Docs regenerated from source (TomeAPI.md, api-reference skill); zero added prompt tokens.
  • Killed the per-tick terrain-collider readiness sweep (ledger #740): physics/simulation-state derived readiness by walking every TerrainChunkCollider entity every server tick plus a per-anchor gate-chunk computation (voxel surface sampling over the place's edit list) — 37–56ms × 19 ticks/sec on Boom Island (300–630ms avgTick), the #1 prod server_behind variant at 42% of episodes. Readiness is now an event-driven dirty-flag index (server-collider-readiness.ts, the terrain-dirty.ts pattern): component hooks on TerrainChunkCollider / TerrainChunkKey / PlaceMembership mark dirty entities, reads drain the dirty set — steady state examines ZERO entities, an edit burst re-examines exactly the rebuilt chunks. Committed bench (bench-terrain-readiness.ts, Boom-Island-shaped fixtures): ~200–750× on 3k–20k collider fixtures, steady and burst.
  • Gate-chunk computation now runs only while the unpause decision is live (paused, forcePhysicsPaused, pre-first-unpause); once hasEverUnpaused is set it can never re-pause, so steady state skips it entirely. Full-sweep escape hatch markServerTerrainColliderReadinessStale fires on terrain definition reinstalls / place-mode flips (same trigger as markTerrainPlaceDirty), and a spec defaultPlace change is detected at drain time. Place attribution (PlaceMembership → spec defaultPlace → "main") is pinned to the old sweep's semantics by test; the client readiness path is unchanged.
  • Tome-UI overlays no longer leak native browser chrome (ledger #751): the kernel suppressed contextmenu per-surface (canvas via createDomManager, touch long-press) but #tome-ui-container was uncovered — right-click on any creator HTML menu opened the browser context menu, and default text selection + image drag-to-save leaked too (specimen: badgerblunts/Manstrosities, where a wisp had to inject oncontextmenu guards on 9 phase roots + user-select:none styles). Two engine defaults now cover every game: (1) mountPostedTomeUiDomHost attaches one contextmenu → preventDefault listener on the overlay container (game + creator targets) and on the full-viewport #tome-creator-mount lifecycle container — propagation untouched, so creator-authored custom context menus keep firing; world right-click (BIND et al) is unaffected because pass-through clicks target the canvas, never the overlay subtree. (2) buildTomeUiBaseStyleRules ships user-select:none/-webkit-user-select:none on the overlay containers only (descendants resolve auto against them, so Tailwind's select-text re-enables selection per element and input/textarea keep selection for free via contain) plus -webkit-user-drag:none on overlay imgs, exempting draggable="true" so intentional HTML5 drag-drop UI keeps working.
  • Game-target UI lifecycle: ui.render named exports onMount/onDestroy now mount on the main thread via the posted dom-host path (previously creator tabs only), with a content-versioned mount key — script edits remount live; mounts survive HUD visibility toggles.
  • WebAudio hatch (engine/audio/hatch.ts): lifecycle modules get a module-scope audio global — the engine's real AudioContext (proxied: destination remapped under the master bus, close/suspend inert, sources tracked), per-bus GainNode gates (user volume/mute + AudioPolicy govern creator audio structurally), and an awaitable ensureDecodedBuffer lane in the shared decode cache (forces buffered decode past the stream heuristic).
  • Engine-owned teardown: unmount/remount/host-dispose stops tracked hatch sources and disconnects all gates — the detached-looping-audio bug class (ghost chorus, ledger 698) is structurally impossible for hatch audio.
  • Worker realm binds audio to an inert absorber so dual-realm module top-level code renders safely.
  • Taught surface: new audio skill (three planes + hatch wiring); musicShift/ambience/stopSound/onSoundEnd docstrings now carry legacy pointers; game-ui's DOM <audio> guidance narrowed to <video>.
  • Re-fold, ledger 771 ch.2 (boot-loop fix): the previous fold's build (e41baeec…, generated 08:40:19Z from e1cc37bbb, two minutes after #7733 merged) carried the compile-hidden frame rail with its co-primary 60-frame deadline but NOT #7739's backstop fix (merged 11:28Z) — fresh 5.1.9 staging apps boot-looped in a ~500ms initializing→loading→starting crash-restart cycle. This entry now pins the 11:33Z build from 94599207b (#7739's merge commit): the frame rail is a pure backstop (180 frames; the 1000ms rail governs, pinned by a simulated-165Hz test), and #7735's client fixes (game-ui mount-key drift recheck + fault-rail report, audio.inspect(), ui-realm-aware reference validation, audio: null spec nullability) ride along.
  • Re-fold, ledger 771 layer 8 (curtain-gate fix): the previous pin (736ba4ea…, built 11:32Z from 94599207b) predates #7752, which makes the loading-curtain gate immune to signal ordering — scene.readyHint is now a re-post-until-acked channel (scene.readyHintAck) instead of a one-shot the host could lose to an attach-after-fire race, the renderer worker stamps firstFramePresented on every steady-state post so a lost one-shot ready re-derives from the stream, and the gate flags are inspectable via __spawnCollectSceneGateSnapshot. This entry now pins the 16:00Z build from 07076eb31 (#7752's merge commit), verified by content: scene.readyHintAck + __spawnCollectSceneGateSnapshot in index.js and scene.readyHintAck in the client runtime worker (all absent from the old build), firstFramePresented stamped in worker-renderer.mjs, with #7739's backstop rail (bSQ=180,hSQ=1000) and #7690's #tome-game-mount still present in both.