Spawn
spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.0For Real1 weekengine v4.6Atelier2 weeksengine v4.5Surface Tension3 weeksengine 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

For Realengine v5.01 weekAtelierengine v4.62 weeksSurface Tensionengine v4.53 weeksSolidengine 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.0.10

Engine v5.0.10

June 11, 2026

A patch in the For Real line.

what's new

  • Fixed a bug where talking to Savi with your voice could get stuck in a silent retry loop if the microphone pipeline failed to start — it would quietly hammer away forever and flood our error logs while voice just didn't work. Now it tries a few times, tells you plainly that voice is off for this session, and typing keeps working (reloading the page may bring voice back). Quick tap-and-re-hold on the mic can no longer be mistaken for a real failure.
  • If music modules ever fail to load in a game, the game now says so once and keeps playing everything it can — including .grain notes, which fall back to plain sample playback — instead of silently re-downloading the broken piece all session or delaying the music while it retries.
  • Fixed the "suddenly I'm sprinting at 10x, then my game breaks" multiplayer bug: when a game server fell badly behind, your character could rubber-band violently and your controls could go dead for minutes. The game now notices within a couple of seconds and snaps cleanly back in sync.
  • Your inputs can no longer get silently eaten after one of these episodes — movement and actions land again as soon as the game resyncs.
  • Your game never freezes while Savi works anymore: her scripts now run beside the game instead of inside its heartbeat, so players keep moving even while she rebuilds half the world.
  • Runaway scripts get stopped cleanly instead of locking the room, and Savi gets told exactly what happened so she can split the work up.
  • Long-running scripts no longer undo things that happened while they ran — if players scored points or took damage mid-script, that progress survives.
  • Edits you make in god mode while a script is running are safe now too: the script re-runs against your latest version instead of overwriting it.
  • Cars and other physics-driven builds hold together now: body panels spawned onto a moving chassis no longer freeze in place, drift apart from the wheels, or vanish entirely after a repair pass. Rebuilding a vehicle while a script error is being fixed no longer produces a dead, invisible car.
  • No creator-visible changes.
  • Changing world settings mid-session (like switching the physics engine) no longer makes script-built vehicles and contraptions fall apart — all their parts stay attached.
  • When a generator script can't run, you and Savi now get one clear message naming the script and what to change — instead of the same cryptic error repeating forever in the background while the object silently renders flat.
  • Generator scripts written with export const / export class just work now; ones using import get told exactly how to switch to require().
  • Cars built from primitive parts under a vehicle parent exist again — the engine's own default for those parts was being rejected by its own validator, so the body pieces silently never spawned and you got an invisible chassis driving around.
  • When a build script does fail while Savi runs it, the error now comes back to her instead of disappearing — she can see what broke and fix it instead of telling you it worked.
  • Loading placeholders are now clean holograms — the translucent bubble around each generating object is gone, and overlapping holograms now layer correctly by distance instead of in arrival order. Scenes spawning many assets at once no longer fill the view with overlapping bubble shells while things build.
  • Savi can now see how your game actually runs on each player's device — including when a device quietly lowered its own render quality in a past session and kept it that way. If your game looks blurry for no reason, ask her: she can check it and (with your OK) reset that device's quality so it re-measures fresh.
  • Savi can also measure multiplayer smoothness now — when something rubber-bands or feels laggy, she can see exactly which part of the game keeps correcting and fix the right thing.
  • Singleplayer mode no longer drops fps while moving on terrain games — the engine was building and tearing down the same far terrain ring about once a second, and now it doesn't.
  • Worlds with animated sprites (2D characters, billboard NPCs) no longer stutter from constant prediction corrections — sprite animation is smooth even in multiplayer.
  • Fixed a bug where pointing a 2D tilemap's tileset at an animated texture turned the whole game black — every tile now draws that texture's first frame instead, and the game keeps running.
  • Fixed: in singleplayer, a hitch (a big particle moment, assets installing, shaders compiling) could get blamed on whatever scripts happened to be running, parking perfectly innocent scripts for 10 seconds with a "blew its tick budget" warning. The watchdog now tells the difference between "your script was slow" and "the game hitched around it" — genuinely heavy scripts still get caught exactly as before.
  • Fixed a bug where players with a profile character loaded their 3D avatar into 2D games — if your game gives the player a sprite, the sprite is what everyone wears now.
  • Fixed a bug where dying to an enemy could leave you permanently dead: if an enemy's attack used the built-in combat module, your own death code (the "YOU DIED" moment, the respawn timer) never got a chance to run — the game just stopped responding to movement with no message. Your script now always sees your health hit zero and gets to decide what death means: respawn, game over screen, spectator mode — your call.
  • Fixed players jittering or getting yanked around while standing on props (docks, bridges, scattered trees) in multiplayer — the ground under you is now exactly as solid on your screen as on the server.
  • Default terrain stops looking like wet plastic. Ground built with pbr: true materials now reads as matte dirt, grass, and rock under full sun — the auto-derived gloss maps were trusted too much and most ground was riding the engine's gloss floor. The texture detail and natural variation all stay.
  • The roughnessIntensity knob now does what its docs always said: 2 is genuinely fully matte (it used to stall at semi-gloss on most textures), 1 uses the derived map as-is, below 1 is unchanged for wet/polished looks.
  • Loading holograms now sit on the ground where the object will appear instead of hovering in mid-air, and grow upward in place as the asset's real size becomes known.
  • Shrink and grow mechanics now feel right out of the box: when a script scales the player, the camera follows the body — first-person eyes drop to the new height and third-person cameras orbit closer — instead of staying stuck at full-size height.
  • Worlds Savi builds now come in noticeably lusher: flower beds, undergrowth, and ground cover are taught ~50% denser and ~50% bigger, and unsized decoration sprites default to a size you can actually see. Bare-ground-showing-through forests should be much rarer.
  • Savi now knows how terrain ground cover actually scales: she builds dense carpets from several plant variants per layer instead of cranking a density number that silently did nothing.
  • Fixed a bug where terrain textures could randomly fail to appear when a world mixes texture sizes — some or all ground materials rendered as flat solid colors depending on download luck. Every texture in a mixed-size set now always shows up.
  • A 2D tilemap floor whose art is still generating (or failing to fetch) now shows a quiet gray loading wash instead of disappearing into black. Your art swaps in the moment it finishes cooking — no reload needed.
  • Savi mixes her soundscapes properly now — ambient beds (wind, water, hum) sit quietly under the game instead of blasting at full volume, with sound effects spiking above them.
  • Debug reports now include a snapshot of what the renderer is holding (draw calls, triangles, shader and texture counts, GPU memory), so when a world slows down we can tell whether something is piling up over time or the scene is simply heavy — straight from the report, without asking you to reproduce it.
  • Savi's edits now read back correctly: getSpec() in a script reflects every property change earlier scripts made this session, instead of the world as it was when the room booted. Read-modify-write edits (like tweaking a scatter you set up a few messages ago) no longer silently undo earlier work.
  • Terrain generators written as heightAt(x, z) or heightAt(x, z, ctx) now just work — the engine recognizes the shape and routes position and helpers correctly. (The documented contract stays heightAt(ctx).)
  • A broken terrain generator can no longer fill your world with mountains you never wrote: faulted samples render flat ground and Savi gets told exactly which function failed and why.
  • If you open your game in two tabs with the same account, the older tab now tells you the game moved to the new tab instead of silently eating your clicks — buys and moves no longer "happen" and then undo themselves in the tab that lost the session. Fixed a rare state where outdoor worlds lit by the sky (no authored ambient light) could lose their ambient fill for the whole session — shadows rendered near-black under a clear sky. The sky's light contribution now continuously self-heals, so even if a frame of it is lost, the world recovers within seconds.
  • Things placed on the ground now stay on the ground when the ground changes. Fences, roads, scattered props, and anything placed at terrain height follow the terrain when it's reshaped — no more boats or race tracks floating in the sky after a terrain fix.
  • Objects you placed at an exact height stay exactly where you put them — the engine only moves things it placed on the terrain for you.
  • If a terrain script has a bug, the ground now stays honestly flat and Savi gets told exactly what's wrong (including the classic heightAt(x, z) instead of heightAt(ctx) mix-up) — instead of the engine quietly inventing hills that everything gets built on top of.
  • Side-view platformer cameras hold a steady zoom: jumping no longer makes the screen breathe in and out. (Cameras with an explicit zoom keep it, as always.)
  • vignette(x) now reads like the knob says: low values draw a subtle rim at the screen edge and 1.0 closes in for real drama. A hidden boost used to max it out early, so if your game has a vignette it will look different — better.
  • Rapid-fire sounds stay punchy: when the same sound effect is spammed past its voice limit, the newest shot always plays (the oldest copy fades out underneath it) instead of new shots going silent.
  • Sprite animations play at their authored speed — no more temporary 8fps guess sticking around after the real animation data arrives.
  • Joining a multiplayer game no longer flashes your character down to a tiny sprite for a moment.
  • Visitors who open a game that hasn't been published yet now see "This game hasn't been published yet" right away instead of an endless loading screen. Publish your game and the link works on the next load.
  • Color grades no longer crush dark scenes to black, and vignettes are round by default — moody looks keep their detail.
  • A script error inside a timer or promise can no longer crash your game's server — you'll see the real error message in the script result instead.
  • Parked cars drive again the moment you hit the gas — vehicles that had settled into their power-saving sleep no longer ignore the throttle.
  • Animations no longer get stuck after a jump: the "jump pose while walking" wedge (a script-side cache surviving a multiplayer prediction replay) can't happen anymore — animation calls are now free to make every tick, and Savi's playbooks build platformer animation that way from the start.
  • Jump arcs animate cleanly through the peak: the taught pattern covers the whole airborne range, so sprites no longer flash their walk or idle pose at the top of a jump.
  • Cars no longer freeze after you hop in: a heavy moment earlier in a script's life (or a busy server) could quietly pause an object's update loop for 10 seconds, and if you mounted a vehicle during that pause it would ignore the gas pedal entirely. Taking control of an object now always lifts that pause immediately.
›technical notes
  • AudioWorklet load-failure ladder (ledger #451): vibe worklet module loading (sidechain-follower.js, granular-player.js) is now memoized per AudioContext with a bounded retry budget (3 attempts, 2s/8s backoff) and a terminal verdict. Previously every Sampler fired its own addModule pair, so a game that stops/starts vibes re-fetched — and re-failed — the modules for the whole session. A terminal-failed context re-arms exactly once when a later vibe restart asks again (a transient inside the original ~10s retry window shouldn't condemn the whole session); after that the verdict is permanent, so scripted restart loops can never become the retry storm again.
  • Vibe start never waits out the backoff ladder: startVibe gates on the FIRST worklet attempt only (Sampler.workletFirstAttempt). On a healthy context that includes the follower wiring (grain + ducking live before cycle 0 — the pre-ladder behavior); on a failing context, synth/buffer playback starts immediately while retries run in the background and .grain notes fall back to plain buffer voices until the ladder resolves.
  • Sampler.workletReady never rejects anymore. A rejecting promise left floating by early-return paths (vibe compile failure, dispose during load) was an unhandled-rejection source; failure is now a state (workletsAvailable: false) instead of an exception, observable via the new onWorkletLoadFailure hook.
  • Terminal worklet failure is honest degradation, not silence or a loop: synth and sample playback keep working; sidechain ducking is off and .grain triggers fall back to plain buffer voices (pitch shifts then also change duration), which complete and release through the same voice paths as everything else (the #213/#214 release invariant). The creator-facing message says exactly that — degraded, not disabled.
  • Exactly one diagnostic per failure, never spam: the renderer routes the first worklet failure per renderer into the vibe error rail (vibe.worklets — the same rail Savi's other vibe errors ride), and one console.error rides the iframe→parent error-forwarding rail to Datadog.
  • The vibe-compile failure path in the audio renderer now disposes the half-built Sampler (told-failure ⇒ released) instead of leaking its node graph and in-flight worklet load.
  • kiln (rides the same merge, deploys with kiln): the studio voice push-to-talk startup had the live prod shape of this bug — the Tab-hold prewarm re-armed a failing startup on every status change, re-running the entire capture pipeline (new AudioContext + getUserMedia + token fetch + worklet fetch) every ~120ms-plus-failure-latency for as long as the key was held, and the unawaited worklet/token/resume promises sprayed unhandled "Unable to load a worklet's module." AbortErrors (95 in one creator session). Startup failures are now bounded (3 real failures → voice off for the session with a plain-language message + one diagnostic), cooldown attempts are silent no-ops that can't re-trigger the prewarm loop, user cancellations are classified as cancellations (no error toast, no budget burned), and permission denials keep their existing dialog flow without ever disabling voice.
  • kiln voice startup attempts carry a per-attempt epoch: a cancelled attempt whose parked promise (getUserMedia / token fetch / worklet) settles after a newer attempt started is recognized as stale — it releases only what it acquired locally and never burns the failure budget, touches the new attempt's refs, or latches the terminal state. Failure and terminal messages now actually render on the studio's sonner toast rail (one fixed toast id — repeats update in place, the terminal verdict shows as exactly one toast).
  • Prediction clock-runaway proactive recovery (ledger #453, dump de3f253e — first during-event capture of the super-sprint/resync class; same mechanism class as #380/#398/#416): when the server's sim clock falls behind wall clock (#333-class stall — the backlog clamp eats the missed time, so the tick clock never catches up) while its authoritative feed stays alive, the client's prediction clock keeps tracking wall time and runs past every reconcilable tick. Recovery used to wait for an accidental resim anchor — which requires a drop-acked ACTION frame — so an axes-only (walking) player wedged for minutes: zero compares possible (server rows fall out of the client oplog window), input dropped server-side, then a missing-data adopt storm anchored on client-domain ack ticks (the dump's mismatchTick=6089 against a server whose newest authoritative state was 3114). The client now detects the breach directly — localTick beyond newestAuthoritativeTick by more than the RTT-aware join lead + 2× the resim cap, sustained for a full cooldown window (30 ticks) with at least one authoritative arrival — and runs the existing #333 recovery (hard baseline adopt + clock rebase, reason prediction.clock-runaway) proactively. Recovery latency for the whole class drops from unbounded (minutes) to ~2–4s of onset. Dead feeds never trigger it (nothing fresher to adopt; free-run until data resumes).
  • Server input-buffer tick-domain guard (TickIndexedInputBuffer): the consumption horizon could adopt the client's runaway clock domain — rebaseOnTooFar (empty buffer) and the post-reset baseline seed both set lastConsumedTick = frameTick − 1 from a frame stamped thousands of ticks past the server's own clock. That pinned the too_late floor in the server's far future, so every frame the client sent AFTER its clock rebase was dropped too_late (and drop-acked at client-domain ticks, re-arming the missing-data storm) until the server's sim caught up to the runaway tick. The buffer now anchors the horizon to the server's own consume clock: frames stamped beyond one buffer horizon of the last consume tick are dropped too_far and can neither seed nor rebase the baseline. Idle-resume rebases (frames near the server's clock against a stale horizon — what rebaseOnTooFar is for) are unchanged.
  • run_script moved off the sim thread (ledger #376, second half): Savi's room.exec now runs on a dedicated per-room exec worker (worker_threads on the server container, a nested Web Worker on the singleplayer authority) against a tick-consistent WorldSnapshot. The script's staged effects return as a serializable TransactionLog merged into the live world in ONE transaction at the tick boundary (order 8, the same point commits always landed) — the simulation never blocks on script execution. Design doc: docs/exec-off-sim-thread.md.
  • Busy-loop kill switch: a script that stops responding past the 5s synchronous budget gets its worker terminated and respawned lazily — the pure-JS busy-loop class that in-thread budgets (#6870) could only document now dies. The watchdog's budget clock starts at the worker's "started" ack, posted AFTER the SnapshotWorld rebuild, so heavy-room rebuild time bills to the rebuild allowance and never kills a within-budget script; rebuild time is measured in-worker and rides exec telemetry as rebuildMs.
  • Granular merge: state bags merge per top-level key, child lists merge by membership, monotonic id counters advance instead of overwrite, append streams ship only their suffix — a multi-second exec can no longer revert concurrent sim progress on state it merely read or partially touched.
  • Spec gate: a merge whose log touches spec state is gated on the spec not having moved since the snapshot — revision + dbVersion + replace-epoch, where revision catches god-mode authoring and behavior-driven spec writes that deliberately preserve dbVersion. A moved spec transparently re-runs the exec against a fresh snapshot (bounded at 2 retries, then an honest conflict error); results carry specMovedDuringExec.
  • Deadline honesty: entries that cannot start (projected queue pressure) or whose results land past their caller's deadline fail fast with honest errors instead of timing out opaquely upstream — a told-failure exec never applies.
  • Snapshot caching: the persistent per-room worker retains the spec by revision and terrain chunk outputs by build signature; steady-state dispatch ships only what changed. Idle workers reap after ~10 min (room-reaper idiom) with lazy respawn.
  • Exec telemetry: snapshotMs (collection + postMessage serialize — the full dispatch stall), snapshotBytes, workerExecMs, rebuildMs, mergeMs, mergeConflicts, queueWaitMs, identitySkippedPatches, workerTerminations recorded per exec.
  • notifyDm in scripts is now delivered with the result (was mid-exec) and still survives rollback — failed scripts keep their diagnostic breadcrumbs.
  • New engine bundles: engine-server-exec-worker.mjs and engine-client-exec-worker.mjs, built and published with the existing artifact pipeline.
  • Ledger #409 (BT's night street racer, dumps 17e3406b / 4df328fc): the player-vehicle assembly broke three chained ways, all from one root — the primitive helpers' parent-aware physics default emitted physics: false, a shape validateSpawnSpec rejects. Every p.box/slab/cyl/... under the car's chassis mount threw spawn(): physics: expected { body: ... } and faulted the whole update() hook, so the car shell never assembled (the invisible car). The original test stubbed the api and asserted the resolver's output without ever passing it through real spawn. The resolver now emits { body: "none" }, and physics: false is additionally accepted everywhere as "no body" (it was the taught-by-error shape, and a natural model guess).
  • The parent-aware no-body default now covers ALL primitive spawn helpers — cone, sphere, torus, pyramid, ellipsoid, ring, plane, circle, torusKnot, hemisphere (+dome/bowl), tube, and the polyhedra had kept the old unconditional pattern and silently minted static colliders inside simulated assemblies. model keeps its no-physics-key default (already body-less under vehicles).
  • Fault scopes no longer survive a same-id respawn: faults are keyed by entity id and the lazy stale-clear in isFaultScopeDead only fires while the id is unoccupied — a destroy + respawn at the same id inside one exec/run_script transaction never opens that window, so the fresh instance inherited its dead predecessor's faults and its onSpawn was silently skipped. In #409 that minted the zombie car: no vehicle physics at first update, so parentHasSimulatedBody read no simulated root and every body panel spawned with the STATIC fallback — BT's wheels-drive-away-from-the-body separation plus a ~25 mispredict-ticks/sec resim storm (104 corrections/282 ms of resim per second in the dump). With reuseExplicitSpawnIds + the hook-mint id lane, later rebuilds ADOPTED the poisoned panels, so the static bodies survived Savi's repairs and the player's reloads. ObjectAPI.spawn's fresh-entity path now clears the id's fault/park scopes (clearBehaviorFaultsForEntity).
  • Pinned red→green in tome/__tests__/ledger-409-car-assembly.test.ts through the REAL spawn pipeline: panels under a vehicle root spawn body-less with the hook completing; explicit physics: false spawns as no-body; setObjectProperty(id, "physics", null) removes the live body config; exec destroy+respawn-same-id keeps onSpawn-applied vehicle physics and the next shell build stays body-less. Session Lab scenarios ledger-409-car-assembly / ledger-409-respawn boot the user's exact spec through the production path and probe the live assembly.
  • Kernel error logs now carry the app they came from (ledger #369, identity half). The room-runtime worker — where every tome.behavior.hook_error originates — binds appId into its log context from the spec row it hydrates (the one source every hydration path shares: /update RPC, WS-first activation, respawned-worker re-hydration), and binds variantId from per-RPC SDK identity (live rooms have no env identity — the ledger #239 boot-env-is-immutable contract stands). The shared client-safe mount-fetch path reports identity through a new setTomeLogIdentity seam in tome-logger.ts; room-runtime installs the server sink.
  • Deduped behavior hook-error ops emission per worker boot (ledger #369, volume half). A broken creator script fails identically on every entity/tick it touches — generator games fired 150-350 identical tome.behavior.hook_error lines per worker respawn (94.7% of all kernel error events). logHookError (src/tome/hook-error-log.ts) now keys on (event, hook, scriptRef, number-normalized error class) and emits the first occurrence full-fidelity, one <event>.suppressed notice when repeats begin, then silence for the rest of the boot. O(1) per error; capacity-bounded with fail-open (untracked classes stay fully visible). Gates ONLY the Datadog/ops emission — getLogs, the Savi DM rail, and client behavior faults are separate pipelines and still see every error.
  • Dedupe lifetime = fault lifetime: applySpecOrThrow resets the hook-error dedupe table at the same seam as clearBehaviorFaults, so new spec content gets a fresh ops signal exactly like it gets a fresh fault slate (a fix-then-rebreak of the same error class reports again instead of staying suppressed for the worker's life). The exec endpoint resets per request — its realm never sees a spec apply.
  • The exec worker carries identity too (ledger #369 review): run_script and its in-worker onSpawn/onDestroy hooks execute on a separate worker thread since #6941, which previously had no winston wiring and no identity — hook errors there fell to the console fallback. The server exec worker entry now installs the winston logger into tome-logger and binds an identity snapshot (appId/variantId/roomId) threaded through createServerExecPortFactory at every worker spawn (watchdog respawns re-read the live values).
  • Datadog ddtags follow bound identity: winston's Http transport reads its intake path per request, so setGlobalContext now refreshes the ddtags whenever identity binds — tag-based DD queries (appId:…) see the same identity as the indexed attributes instead of a boot-frozen appId:unknown.
  • Container-thread + network-worker identity (ledger #369 review, coverage half): the runtime worker relays every identity bind (spec-fetch appId, SDK-config variantId) to the container thread via a log.identity message — the container previously learned appId only from /update payloads, which WS-first live rooms never send — and the container forwards it to the network worker, which has no identity source of its own.
  • Fixed live api.updatePlace(...) (any place-def edit — the mantle/rapier engine flip, gravity tweaks) severing runtime-spawned children from their parent. updateEntityFromDef reconciled TomeChildren against the spec's hierarchy pairs alone, removing the component from any object whose children were spawned at runtime (generator scripts, mounted players). hierarchy-solve's parent-dirty hook gates on TomeChildren, so every passive child of a moving dynamic body froze in world space after one updatePlace — the ledger #420 "wheels drove away from the car body" report (the wheels kept tracking only because the car script rewrites their local transforms every tick; the engine switch itself was innocent — cold-boot mantle was already clean). The reconcile now keeps current children whose TomeParent edge still points at the object and overlays the spec's authored children.
  • Geometry/terrain/spline generator compile failures now report ONCE per content version (ledger #369): compileTerrainGeneratorRef and compileSplineGeneratorRef cache failed compiles the same way compileGeometryGeneratorRef already did, and all three check the cache BEFORE re-running module compilation — a broken generator no longer re-books tome.compile.*_generator_failed / missing_library / module_failed diagnostics on every entity apply, chunk job, or per-tick editor pass. Failure entries self-invalidate on source change; lib edits still recompile via the dependency-aware invalidation sweep (spline/IK/brush caches are now included in invalidateBehaviorCache / clearBehaviorCache, fixing stale closures over edited libs).
  • ES-module syntax in generator scripts is repaired when trivially safe and rejected clearly when not: export const|let|var and export class prefixes are now stripped alongside export function / export default (shared stripModuleExportSyntax across geometry/terrain/spline/brush compilers), and residual module syntax (import declarations, export { ... } lists, export async function) produces a guidance error — "is written as an ES module… use require("lib/...") and top-level function declarations" — instead of the raw parser message ("Unexpected keyword 'export'") plus a silent flat/no-mesh fallback. The error rides the existing compile-error rail (runtime log + deduped Savi DM + client fault) and fires once at author time.
  • The storm-class log lines (geometry_generator_missing_geometry, terrain_generator_incomplete, spline_generator_missing_profile, and the three *_generator_failed events) now carry scriptRef, and the "missing function" messages say "must define a top-level geometry() function" instead of teaching export.
  • Once-per-content-version never swallows the report: a failure first compiled by a reporter-less caller (the per-tick selected-editor probes) stores its compile errors on the cache entry and replays them to the first caller that brings a reporter (compileGeneratorRefCached) — fixes a latent geometry-path bug where probe-before-apply ordering could eat Savi's only signal.
  • The terrain chunk-worker runtime (engine/features/terrain/generator-runtime.ts) strips the same export forms as the spec-apply probe, so a generator the probe accepts can't silently fall back to noise in the workers. Mod-installed input axes can no longer silently swallow built-in aim/movement input (ledger #426, app 808a397d).

A mod that declared one of the engine's built-in axis names (aimYawSin, aimYawCos, aimPitchN, the camPos*/pointer* camera axes, lookX/lookY) got that name namespaced on install — odm-player:aimYawSin — and from then on every read of input.axes.aimYawSin from the mod's own scripts resolved to that namespaced key. Nothing can ever feed it (the engine writes only the bare names into input frames), so the read returned 0 forever on both realms, silently shadowing the live engine value: aim collapsed to {0,0,0}, movement and aim-driven mechanics died, and no fault or DM fired because the key was technically defined.

Two pieces:

  • Spec ingestion now strips namespaced aliases of engine built-in axes ({mod}:aimYawSin and friends) on every apply, on both realms. Existing games that already carry these axes heal the moment they load this engine: the mod script's bare read falls through to the engine-injected value and just works, Savi gets one runtime log + DM naming the removed keys and the read-it-directly pattern, and the next spec edit persists the repaired inputs. Bare declarations and mods' own legitimate axes are untouched.
  • Mod publish rejects engine built-in axis names in mod.json inputs outright, with the same guidance — the dead declaration can no longer enter the registry.

The reserved list is shared (ENGINE_INTERNAL_AXES in @spawn/tome-schemas) and pinned to the engine's actual injection set by test, so the proxy, the ingestion repair, and mod publish can never disagree about which names are the engine's.

  • physics: false is now a valid physics value everywhere (ledger #479): the spawn/property validator (PROPERTY_VALIDATORS.physics), the Zod spec schema (PhysicsSpecSchema), and the ObjectPhysics type all accept it as "no physics body" — the value the engine ITSELF defaults primitive children of simulated dynamic/vehicle parents to since #6685 (resolvePrimitivePhysics). Pre-fix, the first p.box(...) child of a vehicle chassis threw spawn(): physics: expected { body: ... }, aborting the whole onSpawn after visible: false had landed on the parent — the invisible car. The #6685 default itself is unchanged (a static collider nailed inside a simulated chassis is hit by the parent's own contacts and suspension rays).

  • Behavior hook errors now reach run_script's returned logs: reportBehaviorError records its runtime log BEFORE the DM-notifier gate. Exec-worker worlds have no DM notifier (live-channel resource, absent in the worker), so a hook error during a transactional exec previously vanished into server stdout while run_script returned ok with no logs — the swallow that hid #479.

  • The model-loading placeholder no longer renders its two translucent sphere shells (ModelPlaceholder:bubble glass fresnel + ModelPlaceholder:spinner aurora) — the billboard hologram plane (ModelPlaceholder:preview) is the whole presentation (three/extensions/models/placeholder-visuals.ts, ledger 417). Both sphere InstancedMeshes, their TSL materials, and the failedFactor attribute (the shells' amber failed-state tint) are deleted; the failed state still persists and still reads via the "Couldn't spawn …" label, since the placeholder derivation in three/models.ts is untouched. The shells had no non-visual role (no raycast/cull/bounds duty; fade/scale animation lives in animState and drives the plane), so behavior is otherwise identical — the shells' castShadow blob disappears with them.

  • Hologram instances are now depth-sorted: the per-frame instance writes go back-to-front by view depth along the camera forward axis (all holograms are screen-aligned billboards in one transparent depthWrite-off InstancedMesh, so buffer order is blend order). Previously overlapping holograms layered by collect order, so a near hologram could blend underneath a far one.

  • New run_script diagnostics (ledgers #439/#440): api.getClientHealth() returns per-client device health — a quality block (adaptive-quality ladder rung + bottom rung, deepest held row id, renderScale, bloom/half-rate state, opening provenance prior/landing/rung-force, last 8 rung transitions with triggers and crossed row ids, and the frame-budget guard's CPU-vs-GPU attribution) and a prediction block (30s mismatch window with drift/push/skew classification, corrections/s, resim ms/s, baseline-adopt rate, and the top 5 mismatching components each with one srv/cli sample pair). Clients report a compact validated snapshot every ~15s (plus a throttled edge report on rung transitions) over the existing cmd.* command channel (engine.clientHealth); the server mirrors the newest snapshot per authenticated clientId. In multiplayer the read answers for every reporting client (one entry per connected player, joined with display names); in singleplayer the forwarded run_script reads the client authority's own locally recorded snapshot.

  • New api.resetQualityLanding(clientId?) (consent-gated: Savi proposes, the creator approves): pushes a quality.landing.reset control to connected clients (all, or one). The receiving renderer releases every held quality rung immediately through the normal knob reconcile, bypasses the boot-cut release meter for the session (AdaptiveQualityLandingRecorder.noteExplicitReset), and persists the neutral landing tombstone — clearing the cross-session landing. Quality-only and reversible: a genuinely overloaded device re-earns its descent from fresh measurements through the armed early-evaluation window (QualityGovernor.resetLanding). Construction-frozen cuts (MSAA, lighting tier, terrain PBR, IK) restore on the device's next reload.

  • The renderer perf sample's governor block now carries rungId, topReachableRung, opening provenance, the guard's fused signal, and per-transition crossed row ids — consumed by the client-health report only (the DD rollup hop copies fields explicitly and forwards none of them).

  • The adaptive-quality-step boot entry and the render/client-sim perf pointer DMs now name getClientHealth() (and the boot entry, resetQualityLanding()), so a Savi looking at a blurry-session log can reach the read and the consent-gated reset directly.

  • Debugging skill: new "Is It the Game or the Device?" section routing the two symptom families (blurry game → quality block; rubber-banding/warps/"CPU lag" → prediction block, where a per-tick replicated value reads as a push storm).

  • Fixed the singleplayer dual-streamer fight (ledger #286): singleplayer glue runs every server-only system in the client world deduplicated by system NAME, so terrain/chunk-streaming (server) and terrain/client-streaming both survived and fought over the shared terrain/stream/<place>/<key> entity namespace with disagreeing desired sets (client extended desktop bands ~2,600 chunks vs server standard ~700). reconcileExistingChunks made the server pass adopt the client's far-band chunks, evictUndesiredChunks despawned them after the 30-tick keep-alive, the client respawned them and queued fresh builds — re-fired on every player chunk-coord change, a ~1,900-chunk rebuild/evict annulus per second while moving (5 fps; standing still was stable).

  • In a singleplayer world ONE streamer now owns the namespace: the server streaming pass suppresses itself (isSoleClientStreamerWorld — client-mode world + singleplayer spec), and the client pass absorbs the server pass's single unique responsibility, the guaranteed LOD0 chunk set under physics anchors (dynamic bodies / character controllers / awake vehicles always get resident chunks + colliders, derived with the same gatherers and AOI resolution the server pass uses). The guard is client-world-scoped: the room container's server-mode world for a singleplayer spec keeps its streamer, and real multiplayer worlds are untouched.

  • Removed the server streaming pass's write-only ran-tick bookkeeping (markServerStreamingRanTick — no readers anywhere) and documented the glue dedup loophole at the seam so the next differently-named server/client system pair decides namespace ownership explicitly.

  • Sprite frame/time are render-clock state — moved off the replicated component entirely (ledger #225). The sprite animation system is mode:"client" (interpolation phase): it used to advance draw/sprite frame/time (and mixer-selected texture and auto-facing flipX) every render frame while the server never animates sprites, so the authoritative value kept frame=undefined / time=0 forever. The detector compared the shared+aoi component bit-exact and booked that by-design divergence as drift every tick on every animated sprite (PROD: rabbit-meadow-N ×22t, sprite.frame srv=undef cli=varies) — a resimulation per tick in a trivially simple world, the resim cap, and a baseline-adoption teleport on predicted 2D players. Fix is structural: all resolved playback state now rides the client-plane draw/sprite-resolved overlay (replicate:"never" — invisible to both replication and the mismatch compare set), and the renderer composes resolved over authored per field. An interim 5.0.3-cut fix declared correction: { mode: "snap" } on draw/sprite (the #6673 cosmetic-mispredict vocabulary); the overlay supersedes it and the declaration is removed — unlike draw/mixer weights and tween clocks, draw/sprite has no legitimate per-realm divergence left (every writer is realm-deterministic or the server-only metadata size upgrade, which classifies as PUSH for replay-free adoption), so it stays in the compare set at full fidelity and a divergence there is a genuine bug again. No wire change; the taught surface is unchanged (setProperty("sprite.frame", 0) and friends run authored on both realms; the resolved overlay is engine-internal). The sprite clock is rollback-immune by construction: playback state lives outside the ECS and the overlay is client-plane, so rollbacks never touch either — pinned by test alongside a two-world rabbit harness (zero mismatches booked while the resolved clock genuinely runs; a real position glitch on the same entities still resims at full fidelity).

  • Tilemap tileset taps are array-safe (ledger #487): TilemapNodeMaterial.buildColorNode sampled the tileset with a plain .sample(tileUv) and setMap assumed tilesets are never CompressedArrayTexture. The tileset field accepts any texture URL, and the KTX2 transport delivers a layered array for animated atlases — binding one produced the three-arg textureLoad(texture_2d_array<f32>, vec2<u32>, u32) (tilemaps force nearest sampling, so every tap lowers to textureLoad), which matches no WGSL overload. The pipeline never compiled and the invalid pipeline poisoned every command buffer it was bound into — the entire frame went black, not just the tilemap. setMap now mirrors SpriteNodeMaterial.setMap's graph-shape flip: an array tileset rebuilds the graph with .sample(tileUv).depth(0), so a mis-pointed tileset renders its first frame instead of killing the game.

  • WGSL pin for the whole sampler2DArray tap law (engine/materials/__tests__/texture-array-tap-wgsl.test.ts): builds the tilemap (unlit, lit, linear, plain-2D) and sprite (batched + standalone, pixel-filtered frame array — the animated pixel-avatar config every 2D game ships) materials through the backend's real WGSLNodeBuilder and asserts every textureLoad on a texture_2d_array binding carries exactly four args (and every textureSample* at least four), non-vacuously. This is the regression guard for the class of failure behind the 2D staging blackout: the crashing sessions ran 5.0.9 engine bytes minted before the 2D reland's array-aware sprite taps, so the cure for live games is the next engine version carrying master — this pin keeps the class dead on every future mint.

  • GPU validation diagnostics now reach observability (the blackout's root cause lived only in the player's devtools): the renderer worker's console never reaches the page, so rejected-pipeline messages were invisible to the iframe→parent error forwarding. The renderer host re-emits the renderer-pipeline-failed and renderer-gpu-recovery diagnostic messages on the page console (first 5 per session), where the existing forwarding carries them into Datadog with origin: iframe. The diagnostics rail to Savi is unchanged. Parked vehicles now sleep, ending the parked-car resync storm (ledger #416). updateVehicle ran every substep for every vehicle controller and reset the chassis sleep timer, so a parked, driverless car could never sleep — it idled awake forever and any micro excitation (suspension settle, terrain crown, brake-free rolling) became a permanent limit cycle in its replicated lanes. Under prediction that cycle ran in a different phase on client and server (vehicleSpeed ±0.045 in anti-phase in the #416 dumps: each side under the 0.05 velocity epsilon, the pair past it), so the comparator booked a full rollback+replay on most ticks — 18k replayed ticks and 363ms/s of resim in the capture, felt as rubber-banding, eaten inputs, and lag near any parked car. The engine now rest-detects vehicles (dynamic chassis, no held throttle, velocities under thresholds for 0.5s), freezes them at exact zeros (vehicleSpeed: 0 on the wire) and puts them to sleep; stepPhysics skips updateVehicle for sleeping chassis. Sleep rides the wire authoritatively, both sides converge on identical frozen rows, and every existing wake path (throttle/control-lane change, tuning writes, authoritative moves, collisions) resumes simulation. Parked cars also stop paying per-substep suspension raycasts.

  • Behavior budget watchdog no longer bills frame stalls to scripts (ledger #443). The watchdog samples wall clock around each entity update(), and wall clock measures the thread, not the script: a main-thread stall (an additive-FX burst frame, spec-apply asset installs, GPU pipeline compiles, GC storms) followed by the fixed-step ticker's back-to-back catch-up burst could collapse "3 strictly consecutive over-budget ticks" into one contended wall-clock window and park trivial scripts for 10s — several independent ones simultaneously, since the stall contaminated every bracket in the window. Singleplayer was the exposed realm (the client is the authority, ANY entity may park there, and the sim shares the browser main thread with rendering and loads). Two guards in behavior-watchdog.ts: (1) stall grace — beginBehaviorBudgetPass watches the wall gap between behavior passes (scripts cannot create that gap; their time lands inside the pass), and a gap past max(150ms, 3 tick intervals) marks the catch-up burst it mints (≤8 ticks) as sampled-but-never-striking; (2) strike separation — strikes for one scope must be at least a full tick interval of wall clock apart, so back-to-back burst ticks inside one stalled window count as one observation, not N offenses. A genuinely heavy update is unaffected on both counts: its time lands inside the pass (no gap) and its own bracket is the separation, so the 3-consecutive-ticks runaway net is unchanged at steady cadence. Grace state deliberately survives clearBehaviorBudget — a spec apply is itself a stall source, and the grace it earns must cover the post-apply burst. Physics-paused stretches (terrain loading) now also read as one large gap on resume, so the first running ticks after an install grind can't park managers.

  • The authored sprite is the player's visual in 2D places (ledger #509): the 3D starter's player.js re-wears the profile avatar in onSpawn (setProperty('model', profile.avatarUrl + '?animations=…')), and when a game was converted to 2D with that script still in the behavior list, the model setter's 2D branch skipped its sprite conversion for entities that already wear a DrawSprite and fell through to writeDrawModel — stacking the rigged 3D avatar on top of the authored sprite for every joiner with a profile character, in every 2D sprite game. The rule, applied at all four decision doors: in a 2d-side/2d-top place an entity with a sprite keeps the sprite — the model property setter ignores model writes, the spec dispatch (applyAppearanceProps) sheds the model instead of writing it, and the animated3DCharacter: true profile-avatar resolution (configure-at-join, re-spec, and property setter) does not inject. Decisions are pure functions of replicated inputs (spec place mode + sprite), so both realms agree. 3D places and sprite-less 2D entities are byte-identical to before — .png.glb implicit sprite conversion and true-3D models in 2.5D scenes still work.

  • Pinned in tome/__tests__/player-sprite-2d-avatar.test.ts (the live game's exact starter-script shape, both modes, re-spec, non-player sprite objects, and the unchanged 3D/conversion paths).

  • Ledger #510 adjudication probe (tome/__tests__/probe-2d-coin-trigger.test.ts): the 2D trigger pickup contract works end to end — a static trigger-sphere coin against a string-shorthand (physics: "character") sprite player in 2d-side delivers onTriggerEnter, the taught other.tags.includes("player") check collects, and the probe pins that the trigger payload has no other.isPlayer field (the live game's coins gated on it — authoring bug, not an engine gap).

  • builtin/combat's damage() no longer writes the dead flag into the victim's state (ledger #489, found in goblinjo's "Genre Flip"). The killing blow used to patch { health: 0, dead: true } atomically — which made the canonical taught death branch (if (s.health <= 0 && !s.dead) { ... patchState({ dead: true }); runInSeconds(respawn) }) unreachable on the victim: by the time its update ran, dead was already true, so no death announce, no death FX, and — critically — no respawn timer was ever armed. Any game combining the two taught patterns (self-managed player death + enemies attacking via combat.damage) permanently bricked the player on first death, in both singleplayer and multiplayer. Death semantics now belong to the victim's script, mirroring heal()'s documented "revival is a game decision".

  • damage() still no-ops on dead targets: readHealth derives dead from state.dead === true || health <= 0, so a zero-health corpse whose script never writes a dead flag cannot be hurt-event-spammed. Non-lethal hits also no longer write dead: false, so a script-authored dead: true at positive health survives stray damage calls.

  • The returned CombatHealthSnapshot.dead still reports the kill (health <= 0), and isDead() is unchanged.

  • Teaching surfaces updated to state the contract explicitly: skills/combat.md (best practices), skills/npc.md (builtin/combat section), and the @tomeapi-example enemy-AI comment in tome/_examples/game-manager-examples.ts (regenerated docs/TomeAPI.md + skill example blocks).

  • Fixed CDN-model colliders (convexHull/trimesh from GLB cooks) silently missing on one side in multiplayer: cook signatures hash the asset-manifest signature, so manifest skew between server and client made byte-identical cooks unmatchable by signature and the prop had NO collider in the client prediction world — anyone standing on it free-fell every predicted tick and got yanked back by corrections (ledger #503, the staging dock mispredict storm: 133 mispredicted ticks/30s). The asset-identity fallback (resolveColliderMeshByAsset, "converged geometry is the invariant") existed but was wired only into mantle; it now lives in the shared rapier resolver (resolveColliderMesh), so isMeshColliderReady and static body realization converge on geometry across manifest skew on the default engine too.

  • Root cause of the "way too plasticky" default terrain (ledger #501, with receipts): the Patina NRO roughness channel is far darker on real ground textures than the ledger #22 de-shine assumed. The stock terrain pack measures grass at median 0.22 (p10 0.067), gravel 0.33, rock 0.17 — the ×1.4 default multiply lifts the grass median only to 0.31, so the renderer's 0.4 floor clamps it. Most of the default terrain rendered a FLAT 0.4 semi-gloss — glossier than the flat 0.55 sheet ledger #22 originally complained about, because that fix also lowered the floor 0.55 → 0.4 on the assumption the bias would lift typical samples past 0.39. The lighting stack is not the culprit: the material pipeline and floor are unchanged since the #22 fix; the default was simply never matte.

  • A multiplier can't fix maps this dark inside the knob's 0-2 range (×2 leaves the grass median at 0.44), so the roughnessIntensity curve above 1 changes from multiply to a matte-lerp: mix(sample, 1, intensity − 1) (patinaRoughnessNode, terrain-layer-atlas, both top and biplanar side projections). Below 1 the multiply is untouched (polish direction, identical behavior); the curve is continuous at 1; one branchless TSL expression. No clamp band ever flattens the map — its full spatial structure survives at any matte level.

  • PATINA_DEFAULT_ROUGHNESS_INTENSITY 1.4 → 1.7: the stock grass renders ~0.72–0.85 effective roughness (gravel ~0.78–0.84, rock ~0.71–0.90) — dry natural ground with visible variation, glossiest possible default texel 0.7. TERRAIN_ROUGHNESS_FLOOR stays 0.4 — at the new default it never engages; it remains the mirror-gloss guard for authored polish.

  • Pins: PATINA_DEFAULT_ROUGHNESS_INTENSITY = 1.7 (factory.test.ts, parameter level) and the WGSL curve on both projections (terrain-biplanar.test.ts, codegen level).

  • The asset-loading hologram now sits on the surface where the model will land: the billboard plane's bottom edge is pinned at the entity's feet Y (position.pos[1] — the same transform/world-feet-position anchor the ground-aligned model attaches under) instead of centering the plane at the predicted volume's mid-height, which left 0.125 × predictedHeight of air under the disc (three/extensions/models/placeholder-visuals.ts, ledger 497). Before preview metadata resolves, the conservative entity-scale volume is grounded the same way; when expectedMetersHeight arrives the disc grows upward from the ground toward 0.75 × predicted height with the bottom edge pinned every frame. Slope overlap is cropped by the existing depth test against terrain.

  • Built-in cameras follow the control target's scale (ledger #506, "The Button That Presses Back"): first-person.eyeHeight and third-person.heightOffset/distance/lookOffsetY are now measured at body scale 1 and multiplied by the target's effective scale (WorldScale × GeometryScale — the same fold the physics collider build uses, all replicated, so every realm derives the same framing). A shrink button that scales the player now drops the eye/orbit with the body instead of leaving the camera at full height staring into the wall above a crawl door the (correctly resized) capsule walks through. The third-person occlusion probe (cast radius, back-off, pull-in floor) scales with the body too, so a tiny player's camera can follow into tight spaces. Zoom state stays in scale-1 units (a shrink doesn't reset zoom); custom camera scripts are untouched — fully authored — and get getControlTarget().scale to do the same multiply themselves (camera skills + examples now teach it).

  • Object-valued physics.collider is loudly coerced (the session's silent killer-adjacent): scripts keep writing collider: { kind: "capsule", radius, halfHeight } expecting to resize a character's capsule — the dims were silently ignored (the shape derives from the primitive/model), which reads as "physics doesn't follow my resize" and burns retry loops. The named kind is now honored, and a once-per-entity runtime warning (console + getLogs rail) teaches the working primitive: colliders size from the primitive/model and follow the entity's scale property automatically.

  • Adjudication: the reported physics half of #506 is ENGINE-CLEAN and now pinned — tome/__tests__/player-scale-capsule.test.ts proves a scripted scale write rebuilds the rapier character capsule (dims + feetOffset) and walks a 0.32-scaled player through a wall-hole door that blocks the full-size body (the live game's exact gate shape); engine/physics/__tests__/mantle-player-scale.test.ts pins the mantle leg (scaleRef-moved rebuild, signature folds the quantized scale). Camera scaling pinned failing-test-first in tome/__tests__/camera-scale.test.ts.

  • Default decoration card size raised ~50%: decorationCardLayout height default [0.3, 0.5] → [0.45, 0.75] (ledger #502 — Jacob, tasting 5.0.10: Savi's scatter/decoration output reads sparse and miniature; default-size carpets were the size anchor). Doc comment on DecorationItemSprite.height updated to match.

  • Scatter/decoration density+size teaching raised ~50% across the curriculum, per the prompt-craft doctrine (raise the examples, don't add rules):

    • _examples/behaviors.ts scatter examples — forest core 140→210 (poisson 7m→6m so the count actually fits), edges 20→30 (15m→10m), undergrowth 250→375 + scale [0.6,1.0]→[0.9,1.5], flower garden 150→225 + scale [0.8,1.0]→[1.2,1.5]. Wheat field's count: 2400 lied against the 500/bed cap — now an honest 495 at 1.1m grid spacing with the cap named.
    • skills/heightmap-terrain.md decoration taste — density bands 0.1–0.5/1–3/4–8 → 0.2–0.8/1.5–4.5/6–12, taught budget 250k → 300k (the engine's actual DEFAULT_BUDGET ceiling; it was under-taught).
    • skills/world-composition.md Enchanted Forest recipe — ~120 canopy/200 undergrowth/400 cover → ~180/300/500, with the per-bed cap named in-line.
  • The SCATTER_CHILD_LIMIT = 500 clamp (ledger 191/259) is now documented at the source: ScatterSpec.count JSDoc names the cap and the denser-carpet escape hatches (multiple beds, decoration layers), so the generated contract every skill carries stops letting taught counts silently thin. All raised example counts stay at or under 500/bed on purpose — raising the clamp itself waits on the impostor follow-up (renaud's PR #6321, the open octahedral bake pipeline stacked on merged #6319 — master-shaped plan in the ledger artifacts; supersedes the earlier #5612-based note) which makes far-field density nearly free before the cap moves.

  • Ledger #502 aim correction (Jacob: "decorations is the big one — scatter !== decorations"): the density half of the ask lands on the terrain-decoration system as teaching, per the 502 ruling (taste lives in skills; defaults are Jacob's call).

    • Honest-clamp teaching (the dishonest band corrected): heightmap-terrain.md density bullet drops the unreachable "6–12 dense" band (and trims "1.5–4.5" to a reachable "1.5–3") and now teaches the real mechanism — the ask is density × π·maxDistance² split across items by weight, each sprite/primitive item caps at 50,000 instances (~density 0.7 per item at the taught 150m maxDistance), denser carpets come from 3–4 item variants per layer. Same contract documented at the source on DecorationLayer.density (decorations-types.ts JSDoc) and on the DEFAULT_ITEM_CAP constant.
    • #6319 ring-falloff verdict (measured, default 80m chunks / 200m far): the near field is NOT thinned — ring fractions are 0.41/0.40/0.19 (near/mid/far), giving the 0–80m ring the highest per-area density (~0.79/m² for a 50k item vs 0.20 mid / 0.06 far ≈ the intended 1/r² screen-uniform ladder), and the GPU EXP keep-curve is ≥0.99 near, 0.94 worst-case at 200m. The felt sparseness is cap- and density-bound, not curve-bound. Residual finding: ~39% of every item's allocated instances are dead cells (each LOD ring places its count on a square [−r,+r]² lattice and zero-scales cells outside the annulus — the far ring wastes 72%); fixing that is placement-domain surgery (the world-snapped blue-noise lattice is load-bearing), named as a follow-up next to the impostor work.
    • PROPOSED (not shipped — Jacob's call; he chose the item-size lever instead, 2026-06-10): raise DEFAULT_ITEM_CAP 50k → 75k. It is the +50% felt-density lever for cap-bound carpets (most single-texture grass layers): one sprite item saturates at ~0.4/m² over the default 200m disc while the curriculum asks for multiples of that, and the prop-era cap predates sprite cards becoming the modern grass (deprecated grass rides the full 300k budget). The 300k budget would stay the global ceiling (allocateDecorationBudget trims past it), so worst-case engine instance count would not change. One constant + one pinned test + the three doc sites.
  • Regenerated: docs/TomeAPI.md, skill generated-example/type blocks, skills manifest.

  • Terrain layer atlas no longer races on download order (ledger #514): the compressed albedo/NRO arrays used to template themselves off whichever KTX2 slice finished downloading first — its size/format/mip-count became the atlas contract and every later-arriving texture that didn't match was rejected to tint-only fallback. Stock packs mix sizes (grass Patina variants 1024², gravel/rock 512²), so which materials rendered textured was a per-session coin flip; worst case the whole pack flat-tinted until a lucky reload. The arrays now converge deterministically on the pack's smallest source size in every order: full block-compressed mip chains nest bit-exactly, so a larger source joins by dropping leading mips (no transcoding), and a smaller late arrival re-templates the live array down — one CPU mip reslice that reuses the existing buffers plus one async GPU re-upload at arrival time, never per-frame, with the existing revision bump driving the pool-material rebuild exactly like the placeholder→array swap. The retired array stays alive until atlas dispose because live materials may still bind it until they rebuild.

  • The "can't be packed into the terrain layer atlas" warning is now unreachable for well-formed packs and strictly per-material when it does fire: only a source that genuinely can't share the array (uncompressed, different transcode format/colorSpace, or a truncated mip chain that doesn't nest) keeps its own layer on tint — the rest of the pack streams normally.

  • Determinism pin in engine/renderer/__tests__/terrain-layer-atlas.test.ts: feeds a mixed-size pack in both orders (small-first and large-first) and asserts every material lands textured with each source's own texels and that the settled atlas contract (size, mip count, format) is identical across orders; plus re-template-keeps-resident-layers, retired-array lifetime, per-material truncated-chain fallback, and NRO-array convergence.

  • CompressedArrayTextureManager grew updateLayerFromTemplate (write a pre-normalized mip chain) and shrinkToTemplate (re-template to a mip-suffix, resident layers carried over verbatim, retired texture returned to the caller); texture-array-utils grew sliceTemplateToSize + fitCompressedSourceTemplate. Other consumers (primitive texture arrays, placeholder previews) are behavior-unchanged.

  • Ledger #500 (engine half): a tilemap whose tileset never resolves no longer renders the whole ground as an invisible void. The arena 2d-dungeon's blankFrame was every /cdn/ texture failing (cold magic-cdn variants — the .ktx2/?normals=auto transcodes were ungenerated, and the unauthenticated capture browser cannot trigger generation, so kiln 401s); sprites already went LOUD for that window (loading rings / plate wash), but the tilemap's missing-tileset fallback was a 1×1 transparent texture whose every texel discards — silent black, zero page/GPU errors. TilemapNodeMaterial now carries a second missing-map presentation, "cooking": a 2×2 opaque neutral-gray checker (the ground's quiet wash). syncTilemapLoadingPresentation (per-frame, before the transition veil composes) flips a record to cooking once its tileset load outlives the sprite anti-flash window (spriteLoadingPlaceholderRevealed, same 2s pending-clock rule) and back to art the frame the texture lands. Tileset swaps reset to the anti-flash hidden state, so live-edit rebinds never flash the wash. The wash stays on the place's lit/unlit graph (scene lights carve pools on it) but self-illuminates while showing — SpriteLightingModel's emissive slot driven by a uniform that is 1 only while the wash is actually bound — because loading scaffold must read in a dark-authored scene exactly like sprite loading rings do (a 0.55 dim-ambient dungeon renders the unlit wash at ~1% luminance; the void would be back). Both flips are uniform/texture-binding swaps, never a shader rebuild.

  • getTextureState now reports "failed" between retry attempts (getModelState parity) — a failed texture previously read as "pending", indistinguishable from never-requested. Healing is unchanged: retryExpiredFailedAssets keeps re-asking while a subscriber waits (verified: a cold variant that fails N times then cooks notifies the still-armed subscriber and the tilemap heals live, no reload — pinned in renderer-asset-texture-state.test.ts).

  • Session Lab: new screenshot_min_luma scenario assertion (kernel-direct + wrapper-embed) — a named capture's frame must carry a minimum meanLuma. This is the regression seam for "renders black with zero errors" failures (487/500 class). ledger-500-tilemap-lit-local pins the lit-2D tilemap pixel floors (authored-dim ambient, white ambient, zero-light fallback guarantee, unlit); ledger-500-tilemap-cdn401 pins never-silent-black under a permanently failing tileset.

  • Ledger #507 (same cold-variant family, jobs half): job errors can now declare themselves TRANSIENT. TransientJobError (typedefs) carries retriable: true; the worker harness honors the marker instead of inferring retriability by sniffing the message for "network"/"timeout" (a staging 429 burst marked glb-bounds terminally failed — retriable: false — and the model silently never got bounds for the life of the room). glb-bounds classifies its fetch: 429/408/425/5xx and raw network rejections are transient (retried in-worker with backoff against the submit's new retries: 3 budget, well inside the 10s deadline); genuine 404/401/403 on the authenticated server path stays terminal and rides the existing ~30s feature-level resubmit. Pinned: first N fetches 429 then 200 → bounds extracted, job never terminally failed (glb-bounds-job.test.ts).

  • Ambient loudness teaching (ledger #512, "background noises consistently too loud"): every layer that taught Savi ambience either modeled no volume or blessed full scale — playSound/ambience API docs carried zero loudness guidance, the always-on AudioSpec line said gain (usually ≤1) (gain 1 passes), and the skills that order "2–3 ambience anchors" (world-composition, heightmap-terrain) never said how loud. With playSound({ loop: true }) defaulting to volume 1.0 on the SFX bus (vs the engine's own ambience channel at 0.5 × 0.8 Ambience bus = 0.4 effective), her beds came out ~2.5× the engine's intended ambient level. The fix is a loudness hierarchy with real numbers at the layers she reads: <sound> identity block (beds loop at ~0.25, felt not heard), playSound/ambience doc strings (one-shots 0.4–0.8, loops 0.15–0.3; ambience default 0.5, 0.2–0.35 reads as place-tone), the AudioSpec schema line (gain (ambient loops ~0.3, one-shots ≤1)), and the two anchor-teaching skills now show the calls with volumes in them. No engine behavior changed; existing worlds sound identical. Ships to Savi via the generated skills manifest + prompt at the next chat deploy.

  • Parked for Jacob (engine-default changes that would alter existing worlds — proposal only, not shipped): (1) a quieter default volume for looping playSound (loops are beds in practice; a 0.3 loop default with one-shots staying at 1 matches the taught hierarchy), and/or routing positionless loops to the Ambience bus instead of SFX; (2) gapless looping for mp3 beds — looped voices play the raw decoded buffer (source.loop = true) and mp3 encoder delay/padding puts an audible seam click at every cycle, which is the "grating" half of the report; trimming near-silence at loop points or an equal-power crossfade would fix it but subtly changes all existing loops.

  • Renderer resource gauges ride the perf rail into debug dumps (ledger #504). The renderer worker's 1s RendererPerfSample now carries a resources block — programs (compiled shader-program cache size, the material/pipeline-cache growth meter), renderTargets, geometries, textures — copied O(1) off the already-maintained renderer.info.memory counters at sample time (zero per-frame cost; absent on backends that don't report them). The sim worker's 15s rollup folds it last-wins into payload.renderer.resources.

  • kiln (rides the same merge): the dump capture retains the full renderer counters block off every rollup that carries one (RetainedRendererCounters in core/kernel/session-captures.ts — drawCalls, triangles, cpu/gpu frame ms, buffer/texture MB, plus the new resource gauges; validated and bounded like every #284 capture) and the debug-dump diagnostics gain a renderer block + a === Renderer Counters === text section. The #504 staging investigation was dump-blind exactly here: "grows with edits, resets on reload" could not name the growing resource because dumps carried no renderer counters. Two dumps from one session now answer it directly — programs/renderTargets/textures climbing across edits = accumulation; flat caches with a high drawCalls/triangles floor = the scene is just expensive.

  • The DD perf-rollup route is unaffected by construction: its renderer zod object is non-strict, so the new resources key strips at the telemetry hop (dump-only, same lane as hitches.worstRing).

  • api.getSpec() inside room exec no longer answers with the boot spec forever (ledger #519, P1). Root cause was NOT the #376 exec-worker snapshot lane (the WorldSnapshot/blob-cache path faithfully ships the live GameSpecResource, proven by pins for setScript/spawn) — it was the mutation-persistence seam: setObjectProperty writes live components and records a setProperty mutation for kiln's DB fold, but nothing ever folded that mutation back into the room's live spec. The room's own persist echo is deliberately recognized and skipped (#326/#352), so the live document stayed at boot for the whole session, and every read-modify-write Savi ran through exec (read getSpec → tweak → write back, e.g. the g504 scatter respawns) based on stale data and silently clobbered earlier edits.

  • Fix: setObjectProperty (and the dotted-key path) now has its member of the in-memory spec-mirror family — mirrorPersistentSetPropertyInSpec, beside the existing mirrorPersistentSpawnInSpec/mirrorPersistentDestroyInSpec (whose doc comment claimed setProperty was already covered; it only was for god-mode's updateObjectSpec). The mirror folds the recorded mutation into the live spec with exactly kiln's fold semantics (tags/behavior/parent top-level, parent: null deletes, everything else under properties, objects with no authored row skipped — kiln skips those mutations too), gated exactly like the recorder: tracker enabled, not suppressed, persistable id. Behavior-script property writes (tracker off) pay three resource reads and nothing else.

  • Recorder-parity suppress gate added to mirrorPersistentSpawnInSpec/mirrorPersistentDestroyInSpec: scatter/field-bed children spawn under SuppressMutationRecordingResource via api.spawn, recorded no mutation, but WERE mirrored — every persist-mode scatter write grew phantom …/scatter/… rows in the in-memory document that the DB never had. The mirror family now fires iff the recorder fires.

  • Both exec realms agree: in the server room the fold rides the exec worker's TransactionLog (lww spec patches behind the #376 spec gate — staged writes coalesce to one whole-spec value per exec on the wire); on the singleplayer client authority the fold bases on readAuthoritativeSpec (whole-world view, ledger #185) and refreshes tome/unfiltered-spec, so place-filtered worlds neither hollow other places nor lose the next RMW base.

  • Pins in src/tome/exec/__tests__/exec-spec-read-across-execs.test.ts: cross-exec getSpec visibility for setScript / setObjectProperty / spawn, compounding RMW (the exact g504 scatter shape, two generations), no phantom scatter-child spec rows, and the singleplayer place-filtered realm.

  • Ledger #520 (P1): the kernel agent docs taught terrain authoring as heightAt(x, z) while the engine contract has been heightAt(ctx) since the TerrainConfig API was born (#4095, Nov 2025 — the positional shape was NEVER the contract). Savi wrote the documented arity in arena racing-3d, every height sample threw a TypeError, and the engine silently swapped in seeded fallback mountains spanning [-32, 256]; her flatten mark then carved the 150m pit Jacob spawned in. Three-part fix:

  • Docs: apps/cf-kernel/SPAWN_AGENT.md (and generated CLAUDE.md/AGENTS.md) now say heightAt(ctx)/materialAt(ctx). That was the only repo surface teaching the wrong arity — the skills corpus, tome examples, and kiln agent-api docs were already correct or silent.

  • Arity adapter (intuitive-by-default over teaching rules): both generator compile paths (engine/features/terrain/generator-runtime.ts worker runtime and tome's compileTerrainGenerator, via one shared adaptPositionalGeneratorExports) detect the positional shape at compile (fn.length >= 2 — two or more required parameters can never be the single-ctx contract; default/rest params don't count toward length) and adapt the call: fn(ctx.x, ctx.z, ctx) (tileAt: fn(ctx.x, ctx.y, ctx)). Both shapes simply work. Pure argument re-routing — deterministic and identical on server and client, so generator parity is unaffected; the spec-apply probe exercises exactly what the chunk workers run. Chosen over a loud author-time error because the intent of the positional shape is unambiguous and the kinder fix removes the failure class instead of narrating it.

  • No manufactured mountains: a faulted AUTHORED generator now renders FLAT (0 clamped into the vertical range), never the procedural-noise fallback. Closed in both samplers: heightFromDefinition's per-sample catch and resolveHeightmapBaseSampler in jobs/chunk-build.ts (the path that built racing-3d's chunks), plus the compile-failed-module case (generatorSource present, module null), which previously fell into the "no module" procedural pathway. Per-sample faults report once per content version per function through the existing engine-diagnostic rail (terrain-generator-runtime-error — allowlisted since #369 but never emitted until now); compile failures keep their terrain-generator-compile-failed report, with the message corrected from "falls back to procedural noise" to "renders flat". The procedural fallback survives only for definitions with no generator source at all (deliberate pathway) and the engine default terrain (whose baked procedural module is healthy). New broken-generator-flat height pathway in the observability counts.

  • Note for the next cut: the unmerged ledger-480-terrain-resnap branch (5c2c5bada) carries an overlapping fix in heightFromDefinition (flat-on-throw) plus a probe arity-REJECTION. This changeset's adapter supersedes the arity rejection (the shape now works — the probe must not reject it); the flat-on-throw hunks will conflict trivially and resolve to this branch's version, which also covers the compile-failure and chunk-build paths #480 didn't.

  • Tests: engine/features/terrain/__tests__/generator-arity.test.ts (both positional shapes incl. the racing-3d one, ctx helpers through the third argument, materialAt/tileAt, single-ctx untouched, end-to-end through heightFromDefinition), engine/features/terrain/__tests__/broken-generator-flat.test.ts (flat-not-noise for throw/non-finite/compile-broken, vertical-range clamp, no-source procedural pathway pinned unchanged, chunk-build sampler flat, runtime-error diagnostic once per content version), tome/__tests__/terrain-generator-faults.test.ts (positional spec applies silently and resolves authored heights).

  • Multiplayer: opening the same game in a second tab/window of the same account no longer leaves the replaced tab a zombie (ledger #513). The server now sends a session.superseded control frame and closes the replaced LIVE socket with SESSION_SUPERSEDED_CLOSE_CODE (4431) the moment a second connection attaches with the same clientId — both on a Ready replacement (replaceReadyConnection) and on the boot-race variant (a second attach landing while the first join is in flight). Previously the old socket stayed open while the ingress guard silently discarded every input frame it sent: its client kept predicting optimistically (buys/clicks applied locally, then rubber-banded away on the eventual reconnect snapshot), and its auto-reconnect stole the session back — the two tabs ping-ponged each other's input dead. The client treats the superseded close as terminal (no auto-reconnect; reconnecting would steal the session straight back) and shows an honest "opened in another tab" message; taking over again is a deliberate page reload. Grace-window reattaches (flaky network, same tab) are unaffected — the old socket is already dead there and nothing is sent. Ledger #511 (Village Meadow dark-spots): games with a procedural sky and no authored ambient/hemisphere light their shadows from the physical sky's environment capture. That fill was the lighting stack's only render-once, event-ordered term: the IBL cube captured exactly once for a static-sun game (re-captures were purely change-driven), and scene.environmentIntensity was written only at events (skybox apply, fallback-suppression flips from the lights sync). Any silently lost capture/PMREM or mis-ordered suppression handshake left the session with zero ambient fill — shadowed terrain near-black under a clear midday sky, console clean. Two structural invariants replace the fragility:

  • SkyEnvironment re-captures on a 5-second heartbeat even when nothing changed (six tiny cube-face draws + the small-cube PMREM chain at 0.2 Hz; no pipeline or cache-key churn), and its dirty/heartbeat bookkeeping is consumed only after a successful capture, so a throwing capture retries next frame instead of going dark for a period.

  • syncSkyAmbient re-derives environmentIntensity for an active physical-mode procedural sky from retained state every adapter frame (same formula the apply/suppression paths write), so no event interleave can hold the fill at a wrong value for more than one frame.

Healthy frames are pixel-identical (verified against the real Village Meadow spec in Session Lab: authored / no-fill / flat-ambient / noon states unchanged to the luma digit).

  • Terrain-derived placement now samples replicated/spec state (ledger #480): a new sampleReplicatedTerrainHeight (definition generator + marks + replicated terrain:height field layers; voxel: definition + replicated edits) backs every path that BAKES a Y from terrain at authoring time — terrain-relative position resolution (y: { terrain: N } in position-utils/property-helpers/object-api), spline lowering (all 17 sample sites in spline.ts), and scatter placement (resolveSurfaceY + the slope filter via sampleTerrainSurface's new source: "replicated" option). Loaded chunk outputs are never read on these paths: they are realm-local build artifacts that lag a terrain revision until the async rebuilds land — sampling them at spec apply baked the PRE-revision ground (and on a server world could even read the stale CLIENT store), which is exactly how racing-3d's track and cozy-island's boat/fireflies stayed in the sky after their terrain was fixed. The existing re-anchor machinery (spec-apply re-expansion, refreshTerrainAnchors, the runtime field-gated re-anchor system) is unchanged — it now simply reads ground truth that is current the instant the revised definition installs and identical on both realms.
  • Runtime "lived-in ground" queries (sampleTerrainHeight: chunk rescue, camera, NPC steering, api.getTerrainHeight) keep preferring loaded outputs, unchanged.
  • A broken terrain generator never invents terrain anymore: when a generator module exists but its heightAt throws or returns non-finite, heightFromDefinition now returns flat ground (0 clamped into the vertical range) instead of swapping in seeded procedural noise spanning the whole vertical range (the manufactured 0–200m hills content got authored against). The procedural-noise pathway remains only for definitions with no generator module at all. The chunk-build worker fallback chain inherits the same behavior.
  • The spec-apply generator probe (ledger #369 rail — one report per content version via runtime log + deduped DM) now names a wrong signature precisely: heightAt(x, z) / two-plus required parameters reports "expected heightAt(ctx) with a single context argument" (same check for materialAt) instead of the vaguer "returned a non-finite value", and says the terrain renders flat until fixed.
  • Deliberately NOT re-snapped (do-no-harm): literal creator-authored y values (the engine does not know the intent behind a number Savi or a creator computed), players (ledger #186 — players are never terrain anchors), and spline points authored with literal heights without snapToTerrain.
  • Tests: terrain-revision-resnap.test.ts (spline children re-snap under stale loaded chunks, the cozy-island { terrain: N } + scatter shape, the literal-y do-no-harm pin — with divergent stale server/client stores installed to catch any realm-local read) and terrain-generator-faults.test.ts (wrong-signature reported by name; flat — never procedural hills — for a broken generator).
  • 2d-side ortho projection no longer infers zoom from camera height (resolveProjectionCameraState, src/tome/systems/camera-behavior.ts): in side view the camera rides at roughly eye height, so the vertical delta is a small number that breathes with every jump — an unhinted custom camera inferred orthoSize ≈ 1.6 and zoomed harder mid-air (Frontline Push). Cameras without an explicit zoom now take the mode default; explicit zoom/orthoSize (runtime state first, then config) still wins.
  • vignette(x) response recalibrated (updateVignetteOverlay in src/tome/systems/juice-client.ts + the look-pass vignette node): intensity now drives the geometry — opacity eases as 1−(1−i)^1.6 and the feather widens with it (0.2 + 0.45·eased), so low values read as an edge rim and 1.0 closes in. The hidden ×1.4 intensity boost died with it (it saturated everything above ~0.7: vignette(0.5) rendered ~85% opacity and 0.72–1.0 were identical). Existing games with authored vignettes render differently — more range, less drowning.
  • One-shot SFX governor: at the 8-voice per-clip cap the newest sound now always plays — the clip's oldest in-flight copy is stolen and its tail fades under the new attack transient (cull fade widened 15ms → 50ms so the cut is masked) instead of the new trigger being dropped. Under spam fire (~20 triggers/sec on one gunshot clip) fresh onsets no longer go silent; the 30ms retrigger floor is unchanged.
  • Sprite atlas hydration honesty (ensureSpriteAtlasHydration, src/features/SpriteAnimationFeature.ts): the smart-inferred placeholder ({4×4, fps: 8} guessed from the URL) is no longer treated as hydrated state — it neither blocks the real KTX2/PNG metadata fetch nor outlives it, so idle and one-shot clips play at their authored fps/frame-count/loop once the atlas metadata lands.
  • First client spec apply preserves replicated state: a joining multiplayer client's first applySpec ran the player-template reconcile against oldSpec === undefined, diffing every property as changed — stripping the player's replicated DrawSprite and re-running onSpawn client-side without the server-hydrated auto-size (the join briefly rendered the player sprite at 1×1 until netcode repaired the self-inflicted divergence). The reconcile is now gated on oldSpec presence. The render layer keyed model visuals on entity id and treated a same-id, same-model draw/model re-add as "already present" — it only refreshed the material variant and returned, never re-attaching the mesh. That short-circuit was correct only when a representation was actually attached. When the visual record survived but its representation was lost (a long-idle tab dropped the local entity's mesh; an asset still loading whose pending subscriber was never re-fired), the re-add hit the same short-circuit and the entity stayed invisible forever — the per-frame pack (packModelVisuals/syncModelLods) only revisits visuals that already hold a representation, so a re-add is the only thing that can re-attach this entity.

Fix in apps/cf-kernel/src/engine/renderer/three/models.ts (setModel): the same-model material-variant short-circuit is now gated on visual.representation !== null. A same-model re-add to a representation-less visual falls through to attachModelVisual, which re-drives the load — a ready asset attaches now, a still-loading asset re-subscribes (replacing any lost subscriber). Model-id changes (the detach-then-attach branch) are unchanged.

Failing-test-first: model.test.ts > "rebuilds the visual when a same-model re-add reaches a representation-less record (ledger #316)" — a model add that resolves to no attachable representation, followed by a same-id same-model re-add once content is available, now produces a static batch. Red on the old short-circuit (0 batches), green after. Every kernel log line and Datadog intake now ships the booted engine version (ledger #421). cf-edge places the engine semver in container start env as SPAWN_ENGINE_SEMVER (the same value that keys the GAME_CONTAINER DO — see room-runtime.ts's engine identity metadata), but the logger never read it, so DD had no engineVersion to filter logs by — the version-tags gap the client-forensics follow-up (#441) named.

Fix in apps/cf-kernel/src/_entry/server/logger.ts:

  • resolveLoggerIdentity resolves SPAWN_ENGINE_SEMVER into a new engineVersion: string | null field on LoggerIdentity (trimmed; null on older cf-edge / local dev where the env is absent).
  • The booted engine version is seeded once into globalContext so every log line carries it as an indexed engineVersion attribute. Unlike appId/variantId, the engine version is boot-immutable — it keys the DO and never rebinds mid-session — so it does not ride setGlobalContext's rebind path.
  • The DD intake ddtags (buildDatadogIntakePath) now includes engineVersion:<semver> alongside stage/appId/containerInstance, so tag-based log queries in DD can scope to a specific engine version.

The other half of #421 (the input-pipe-starved engine diagnostic being dropped by the server) was already fixed on master — "input-pipe-starved" is in ALLOWED_DIAGNOSTIC_CODES (tome/engine-diagnostics.ts), shipped via ledger #406, and is exhaustively covered by the existing engine-diagnostics test. No change needed there.

Failing-test-first: logger.identity.test.ts — resolveLoggerIdentity reads the engine version from SPAWN_ENGINE_SEMVER (and leaves it null when absent), and the DD intake path carries engineVersion:<semver> from boot. Red on the old code (engineVersion undefined, tag absent), green after. The renderer's model-not-animatable diagnostic (apps/cf-kernel/src/engine/renderer/three/models.ts, warnModelNotAnimatable) fired one message for every static model carrying a named draw/mixer clip: "rebake it with animations … or point the entity at a rigged model" (ledger #307). That advice is correct for a spec model Savi authored, but wrong for a profile-injected player avatar (animated3DCharacter: true) — that model is the session owner's profile avatar resolved at runtime, which Savi can't rebake or repoint (ledger #430).

The two cases are distinguishable at the renderer by the mixer's channel set. The auto-locomotion feature (Animated3DCharacterLocomotionFeature) is the only thing that reads DrawAnimated3DCharacter, and it compiles that config into draw/mixer channels keyed idle/walk/run — so the renderer (a separate realm that only sees the resulting mixer) treats that exact key set as "engine-authored avatar locomotion" rather than a creator's own channels on a spec model.

  • A3DC_LOCOMOTION_CHANNEL_KEYS is now exported from draw-animated-character.ts (the canonical home of the a3dc concept). The locomotion feature keys its writeChannels call from the constant and the renderer's new isAvatarLocomotionMixer matches against it — one frozen source of truth, so the renderer's classification can never drift from what the feature writes.
  • warnModelNotAnimatable branches on isAvatarLocomotionMixer(visual.mixer): the avatar case names the limitation and ends "leave it as-is" (no repair verb); the spec-model case keeps the original rebake-or-repoint nudge verbatim. Diagnostic code, dedupe key, and data payload are unchanged.

Failing-test-first: model-not-animatable.test.ts — an avatar locomotion mixer (idle/walk/run) reports once without "rebake" or "point the entity at a rigged model", and a creator's spec-model mixer still primes repair. Red on the old single-message code, green after. Dead-zone the vehicle's published forward speed near zero (ledger #434). currentVehicleSpeed() is the SIGNED projection of chassis velocity onto the forward axis; at near-zero speed the chassis micro-velocity wobbles around zero, so that tiny projection flips sign every couple of ticks — and client and server, running independent solver noise, flip on different ticks. The wheel-state writeback published the raw value straight to the replicated PhysicsVehicleConfig.vehicleSpeed lane, where the mismatch comparator forgives magnitude (DEFAULT_VELOCITY_EPSILON 0.05) but not a flip: server +0.02 vs client −0.02 is a 0.04 delta from the sign alone, and across two ticks the anti-sign pair routinely exceeds the epsilon — a parked car booked constant corrections (~363ms/s of resim). Distinct from #416's rest-sleep: a car under a held tuning lane (brake/steering/feather throttle) never reaches the sustained-rest window, so it never sleeps — but its forward speed still lives in this band.

Fix in apps/cf-kernel/src/engine/physics/rapier/features.ts: at the single writeback seam, const stableSpeed = Math.abs(rawSpeed) < VEHICLE_SPEED_DEADZONE ? 0 : rawSpeed before quantizeF32 and the world.set. VEHICLE_SPEED_DEADZONE = 0.05 (= DEFAULT_VELOCITY_EPSILON): the comparator already forgives this much, so a value in the band can never read nonzero on the wire. Same threshold and same math on both realms ⇒ parity by construction (no replicated state, no per-side branch), and the band is far below a real driving speed so a moving car is untouched. New failing-first test vehicle-speed-deadzone.test.ts reproduces the sign-flip (raw ±0.02/±0.03 near-zero nudges) and pins exact-0 publish + anti-sign two-world equality + real-motion passthrough. In singleplayer, run_script lands on the server room but executes on the player's client authority (tome/script-forward-server ships it as a script.exec control message; the client answers over the wire). When there was no client to service the forward, the system kept the entry queued for the full FORWARD_SCRIPT_TIMEOUT_MS (20s) deadline before resolving with "no connected player client" — and Savi's exec RPC sits above that, so the felt wait was ~30s of nothing (ledger #444, 132/395 family).

Two no-client shapes both incurred the full wait:

  • Empty / no-Ready connection (tab fully disconnected): findAuthorityClientId returned undefined and sendPendingForwards treated it as a mid-join window, keeping the forward queued until the deadline.
  • Frozen / backgrounded tab (socket dropped, connection still Ready with detachedAtMs set in the disconnect grace window): findAuthorityClientId only checked phase === Ready, so it picked the detached connection as the authority and queued the script.exec into a controlOutbox that egress never drains (the egress loop skips detachedAtMs !== undefined). The forward then timed out at 20s with "the player's client did not respond". DISCONNECT_GRACE_MS is the same 20s as the forward deadline, so a frozen tab guaranteed the full wait.

Fix in apps/cf-kernel/src/tome/systems/script-forward.ts:

  • findAuthorityClientId now also requires detachedAtMs === undefined — a detached Ready connection can't receive the message (same predicate egress uses), so it is no longer treated as a serviceable authority.
  • New hasConnectingClient(world) returns true only for a genuinely mid-join client (phase === Loading) — the boot-race window where the script.exec will become deliverable any tick. A detached Ready connection is explicitly NOT connecting (socket gone, grace window == forward deadline).
  • sendPendingForwards: when there's no serviceable authority AND no mid-join client, the unsent forwards now resolve immediately with the honest "no connected player client" error rather than waiting out the deadline. A real mid-join client still keeps the forward queued until either delivery or the existing timeout.
  • The "no connected player client" message is now a shared NO_CLIENT_REASON constant used by both the fast-fail and the (still-valid) timeout path for a mid-join client that never finishes joining.

No new code path or bridge — the existing pending/deadline machinery is unchanged; this only decides per-tick whether the no-authority case is "wait for a connecting client" or "answer now".

Failing-test-first: script-forward.test.ts — "fails fast when there is provably no connected client" (empty table, honest error on the first tick) and "fails fast when the only connection is a frozen/detached tab" both red on the old code (resolved undefined after one tick), green after; "keeps waiting through a genuine mid-join window" pins that a Loading client still gets the full deadline. The old "times out loudly when no client is connected" test (which asserted the empty-table case waits the full timeout — the bug) was removed; its scenario is now covered by the fast-fail test. Adds a path shape to the heightmap flatten mark (ledger #482). Previously a road was authored as many independent circle/rectangle flatten marks, one per "pad". With height: { terrain: offset } each pad resolved its target height from the natural terrain at its own center and lerped to it independently — so adjacent pads flattened to different heights, reproducing the terrain's relief as a chain of steps (the rollercoaster). There was no grade relationship between consecutive pads and no shared/interpolated surface along a path.

The fix makes the road one mark with a continuous, grade-limited height profile:

  • marks-types.ts: the flatten shape union gains { kind: "path"; points: [number, number][]; width: number } plus an optional maxGrade (default 0.25 ≈ 14°). Schema mirror added in @spawn/tome-schemas (TerrainFlattenShapeSchema).
  • marks.ts resolveFlattenPathProfile: samples the centerline with the existing centripetal spline (same sampler rivers use, so curved roads read as the curve), resolves each sample's target height from the natural terrain (or absolute), then runs a forward+backward min-pass clamping each step to maxGrade * segmentLength. The result is a monotone-grade-limited surface that still tracks the terrain everywhere the grade allows. The profile is stored on the mark entry and read at a point's arc-length projection (sampleFlattenPathHeight), so the whole strip flattens to one road, not per-point pads. An absolute-height path stays a single flat road (no grade pass needed).
  • computeFlattenShapeDistance / resolveMarkBounds handle the path strip (distance to polyline minus half-width; bounds = polyline AABB expanded by half-width + falloff).
  • scatter.ts: a path-shaped flatten clears scatter along its strip + falloff band (a road shouldn't grow trees on it), matching the existing area-shape exclusion.

The grade limit lives entirely in the height-profile resolution (a pure function of the centerline samples + natural terrain), so server and client produce identical roads. Area shapes (circle/ellipse/rectangle) are unchanged — they already flatten to a single height. This is the engine half of #482; the skill/authoring half (teaching Savi to reach for path flatten over dotted pads) is separate.

Failing-test-first: marks-flatten.test.ts > path-shaped flatten with terrain-relative height limits grade between pads — a road centerline crossing a grade-2.0 natural step; the observed surface grade between dense samples must stay ≤ maxGrade (0.25). Red on old code (the step passed straight through at grade 2.0), green after. collectSpecAssetEntries (spec-assets.ts:207-213) iterated place.terrain.materials with a bare for...of guarded only by kind === "heightmap". TerrainHeightmapDef.materials is typed Array<...>, but the value here is the raw, un-Zod-normalized persisted spec — and a heightmap terrain can carry materials as an object {} (voxel terrain's materials is a Record, so a place flipped voxel→heightmap, or a raw definePlace/updatePlace that authored materials: {}, leaves a non-array behind). Iterating an object throws TypeError: {} is not iterable, which applySpec's try/catch logs as tome.apply_spec.failed and aborts the entire apply (ledger #486; the cross-game trickle co-occurring with terrain "Material id is required" — terrain-config's validateMaterials already handles !Array.isArray(materials), asset collection did not).

Fix in apps/cf-kernel/src/tome/spec-assets.ts: the iteration guard now requires Array.isArray(place.terrain.materials) instead of mere truthiness — matching terrain-config's own posture (non-array materials = no terrain materials to collect). A malformed object degrades to "no terrain texture assets from this place" rather than crashing the apply; a real array is unaffected.

Failing-test-first: spec-assets.test.ts — collectSpecAssetEntries over a { kind: "heightmap", materials: {} } place. Red on the old code (the exact {} is not iterable TypeError), green after (returns []). The exec deadline-admission projection (ExecHost.expireDeadEntries, ledger #376 exec-off-sim-thread) charged every queued lane ahead the WORST-CASE per-exec ceiling (scriptSyncBudgetMs + watchdogGraceMs = 7s with defaults). Real callers give run_script a 28s deadline (EXEC_QUEUE_DEADLINE_MS), so the 5th queued entry projected its start at 4×7s = 28s — right at the deadline — and was failed INSTANTLY with the queue-pressure error, even when every exec was actually completing in milliseconds (ledger #533).

Fix in apps/cf-kernel/src/tome/exec/host.ts:

  • The projection now uses REALIZED/measured time, not the worst-case budget. The in-flight lane is charged its TRUE remaining watchdog time (watchdogDeadlineAtMs(inFlight) - now — it has been running; the watchdog frees it by that clock). Each queued lane ahead is charged realizedLaneHoldMs, an EWMA of actual dispatch→free wall-time across completed execs.
  • realizedLaneHoldMs is undefined until the first exec completes, so a cold queue projects off the in-flight lane's true remaining time alone (queued lanes charge 0) — fast execs are admitted instead of pre-failed on a worst-case guess. It is folded in settle (normal completion) and terminateInFlight (a watchdog kill is the slow-lane evidence), EWMA α=0.5 so one slow exec lifts the estimate and a run of fast ones decays it.
  • A genuinely slow queue still bounds: a stuck in-flight lane's remaining watchdog time (and a climbing realized estimate once slow execs complete) carry the projection past short deadlines, so entries that truly cannot make their deadline still fail fast with the honest queue-pressure error.

Failing-test-first in apps/cf-kernel/src/tome/__tests__/script-dispatch-budget.test.ts: "runs five fast queued execs even at the real 28s caller deadline" — red on the old code (5th entry pre-failed at tick 1), green after. The existing "fails fast an entry whose projected start is past its deadline" test was updated to express the bound through the in-flight lane's true remaining watchdog time (the honest mechanism) rather than the old worst-case per-queued-lane charge. Static (fixed) rapier colliders now re-place transform-driven instead of callsite-driven, fixing a drift-dominant player-feet misprediction storm when a parented static-physics child is moved (ledger #550, dump 1e06e2e4). A fixed rapier body (RigidBodyDesc.fixed) never re-places itself, and syncWorldFromRapier's stable/signature fast paths skip statics entirely — so a moved static's collider only tracked the move at three special-case callers (spec-apply transform edit, root script-move, parented-child hierarchy solve). The client hierarchy solve is gated to client-predicted entities, so a server-realm furniture child Savi dragged updated its replicated WorldFeetPosition/WorldRotation (mesh moved) but never re-placed its collider on the client — one-sided collider lag, the player colliding with stale geometry and resimming every tick.

Fix in apps/cf-kernel/src/engine/physics/rapier/sync.ts:

  • New staticTransformReplaceNeeded(world, runtime, entity) — a REALIZED static's collider transform is now a pure function of its replicated WorldFeetPosition/BodyPosition/WorldRotation, compared each tick against the realized-pose caches (getPositionCache/getCenterCache/getRotationCache, the same caches entityNeedsTransformSync uses) via the existing positionsDiffer/rotationsDiffer epsilon.
  • syncWorldFromRapier's stable-static fast path and signature-match skip now consult that check; on a real transform delta they dispose+rebuild the body at the new pose (disposeStaticForTransformReplace, which first aligns PhysicsBodyState.rotation to the current WorldRotation so the rebuild's initializeBodyState orients correctly — mirrors the existing realign in syncPhysicsBodyToComponents' static branch).
  • Identical input on both sides (replicated World* transform) ⇒ parity by construction; no client-only state is read, so the fix removes the dependence on the client-prediction gate rather than widening prediction.

Cost: the re-place is epsilon-gated against the cached realized pose — a stationary static reads false and stays on the zero-cost stable fast path (no per-tick destroy/rebuild churn). Dynamic and kinematic body handling is unchanged.

Failing-test-first: static-collider-transform-tracking.test.ts (server + client worlds) — a moved static's rapier collider translation/rotation now tracks its replicated transform after a plain syncWorldFromRapier, and a stationary static keeps the same body identity across 18 ticks. Red on the old code (collider stuck at the old pose), green after.

  • Live rooms for unpublished games no longer retry spec hydration forever (ledger #553): after ~90s of deterministic "no publish exists" 404s the room goes terminal, rejects joins with an honest room.unpublished control frame + close code 4432, and emits the spawn.kernel.spec_hydration_stuck error event. Transient fetch failures keep the unbounded retry; the publish-press race (publish row committing 20-90s after a live room boots) still resolves through the retry. A later publish poke or a fresh boot clears the terminal state.
  • gameSpec.latest() (gsdk) now returns a structured { notFound: true, reason } on 404 instead of null, carrying kiln's resolver reason; the kernel's "No game spec found in Supabase" error string is replaced with the real reason.
  • Fixed a duplicate GameRoomRuntime booting in the container main thread (Bun's main thread has globalThis.postMessage, which the worker-entry guard mistook for a worker context) — this was doubling spec-hydration fetches and running a second sim pump for the boot room. God-mode right-click selection only committed for authored (in-spec) entities: commitTapSelection in tome/god-mode/systems/click-to-select.ts gated on isAuthoredEntity, and non-authored hits fell through to the terrain path as a silent deselect. Meanwhile the designed affordance for exactly this case — the lockedRuntimeEditor rule in default-editors.ts (a single "Edit with Savi" action chip that DMs Savi the object id) — had been unreachable since it shipped (#6496): the rule resolves off the SELECTION, and selection could never land on a runtime entity. Jacob's ruling (ledger #563): locked entities select and surface the chip.

Fix:

  • commitTapSelection now also commits when the hit resolves to a selectable runtime entity: renderable presence (DrawPrimitive/DrawMesh/DrawModel/DrawSprite/DrawText/DrawAnimated3DCharacter), not a player (TomeBehaviorRef.specId === "player", the same check isLockedRuntime uses), not a gizmo (already excluded via anchorEntityOfGizmo), not god-mode tooling (below). No editor/applier changes needed — applySelectionState never gated on authored, and the selected-cluster pass publishes the locked chip through publishActionPanel once selection lands (pinned by a new integration test).
  • The original reason for the authored-only gate stays fixed: the selected scatter-bed's footprint-outline ribbon (selection-outline.ts) is real raycastable geometry draped on the terrain, and selecting it back was the "can never deselect the bed" trap. Engine-internal god-mode helper entities (footprint ribbon, selection-rim twins, feedback-sfx one-shots) now share a tooling id prefix — GOD_MODE_TOOLING_ENTITY_PREFIX / isGodModeToolingEntity in tome/selection-utils.ts, the same id-marker idiom gizmo exclusion uses — and commitTapSelection skips marked tooling in the hit scan, so a click on the ribbon falls through to the terrain/footprint path (lands on the bed or deselects).
  • Behavior pinned unchanged: authored entities select exactly as before (root-first drill-down included), players and renderless runtime helpers don't select, gizmo hits still resolve to their anchor, terrain/empty still deselects. A selected runtime entity gets ONLY the chip — no transform handles (nothing to write back to).

Failing-test-first: tap-select.test.ts > "a right-click tap on a runtime-spawned (non-spec) renderable entity selects it" — red on the old gate (ids: []), green after. New pins: ribbon/player/renderless-helper right-clicks stay on the deselect path; default-editors.test.ts > "selecting a runtime-only entity publishes the 'Edit with Savi' chip — and nothing else — to the action panel". setScript parks the spec snapshot it wrote in TomeSpecUpdateResource for the tick-boundary apply. A later fold in the same run_script (mirrorPersistentSetPropertyInSpec and the whole mirror family) advanced GameSpecResource without refreshing that parked request, so specUpdateSystem drained the setScript-era snapshot wholesale and erased the fold from the live spec — while ScriptMutations persisted both writes, which is why the value "reappeared" on reload. Same mechanics on both realms (exec-worker overlay via the TransactionLog, live-world withPersistence).

Fix in updateSpecResource (src/tome/api/object-api.ts): after a successful head write, a pending request that IS the previous head advances to the new head (reason/baseline preserved), so the drain commits every fold in program order. The gate is reference identity on live worlds and the already-trusted structural hash inside the exec overlay (its setResource clones staged values, so identity dies at that boundary). Writers that request applies without writing the head (setObjectProperty.behavior et al., placeCleanup) fail the gate and keep their existing semantics. The fold still runs inside the same transaction/tick — no new ordering, deterministic-sim contract intact. Repro-first regression test drives the production path (inline exec transport, script-dispatch → spec-update → mutation-drain): 3 of 4 cases failed pre-fix, 4/4 green now. drawn-art.md's defensive ref-first ordering died with the bug. The interpreter's spec→world path (applyAppearanceProps, src/tome/interpreter.ts) rebuilt kind:"custom" geometry from a hand-copied field list carrying only positions/indices/normals/uvs/revision — dropping colors, emissive, metalness, roughness — and hardcoded materialKeyForBespokeVertexPbr(key, false), so even carried buffers would have rendered through the non-VPBR material. The primitive property getter stripped the same four arrays on read-back.

BespokeGeometrySpec is directly assignable to BespokeGeometryInit, so both the interpreter and the authoring setter (src/tome/api/properties.ts) now call createBespokeGeometryValue(spec.geometry) whole — no field list anywhere left to drift — and the material key derives from hasVertexPbrGeometry like the live path. Read-back returns the full geometry; BespokeGeometrySpec gains the textureRuns field the engine value already supported, making spec ↔ component round-trips total. Regression test drives applySpec end-to-end (paint arrays + textureRuns + VPBR material key + property read-back) and was verified red against the old code. quantizeWheelNotches (src/engine/input/raw-capture.ts) claimed "exactly one WHEEL_NOTCH per physical detent" but accumulated raw deltaY against fixed thresholds (100px / 1 line), which was false under macOS acceleration (a single detent's delta ranges from a few px to several hundred → 0 or 2+ notches) and on 3-lines-per-detent OS settings (3 notches per click).

The quantizer now normalizes per deltaMode (pixels ÷ 100, lines ÷ 3, pages = 1 detent) and emits at most one notch per wheel event. Real devices deliver one event per physical detent — a fast flick is a burst of events, one per click — so the per-event clamp is what makes one-pulse-per-detent true under acceleration. Sub-detent leftover carries forward (trackpad fractions and 1-line OS settings accrue into clean single steps); overshoot beyond a whole extra detent within one event is acceleration inflation, not future intent, and resets instead of banking a phantom notch. Direction-reversal and focus resets unchanged. processWheelNotch's same-frame collapse comment now states what its test pins; game-ui.md teaching is test-pinned per claim. 12-case device matrix added; 134 input tests green.

  • Dark scenes survive their grade (round-3 god-rays anatomy): the look pass's contrast pivot now sits at sRGB-0.5-in-linear (~0.2140, one exported constant shared by scripted gradeFx and the vocabulary grade) instead of effectively pivoting at sRGB ~0.73 — contrast no longer crushes everything below ~sRGB 0.19 to black. Vignette default is circular (was a diamond falloff at wide aspect).
  • Transactional run_script guards can no longer kill a room (ledger #560): all seven "not available in transactional run_script mode" guards route through a sticky fault channel — when creator JS swallows the guard throw (Promise executor, .then chain, try/catch), the exec fails at the result boundary with the explanatory message instead of silently returning ok:true with the call dropped. Process-level unhandledRejection nets in both the runtime worker and the exec worker contain script-origin floating rejections (Bun Workers' Web-style unhandledrejection listeners never fire — any float was previously fatal to the room); sync crashes still escalate loudly.
  • Fixed: a rest-slept vehicle chassis (#416) could be stranded permanently asleep under held throttle (ledger #580). Three layers: setWheelEngineForce/setWheelBrake/setWheelSteering now defer to merge as typed deferred ops under the script-transaction overlay (the inline path landed the component row but dropped the controller poke + body.wakeUp() — null runtime on the overlay world); syncVehicleControllers reconciles a replicated wheelStates row holding nonzero engineForce on a sleeping chassis (reseeds controller lanes, wakes, clears replicated sleep status); updateVehicleControllersForSubstep wakes a sleeping chassis whose controller holds nonzero engineForce, every substep. Rest-sleep for genuinely parked (all-zero-lane) vehicles is unchanged.
  • updateChannel no-ops on identical patches (object-api.ts): a patch that lands exactly on the current channel state skips the write entirely via an allocation-free field compare — cheaper than the store-level recursive deep-equal it short-circuits. One-shot re-fires are structurally exempt: a clip-named call on a loop: "once" channel bypasses the compare before suppression is possible, so retrigger semantics are byte-for-byte unchanged. No realm branch in the gate (inputs are replicated state + script args + tick), so client/server stay symmetric under prediction resim.
  • 2D skill corpus: the platformer worked frame gates locomotion weight on grounded-and-moving and gains an air channel covering the whole airborne range (no vy dead zone at the jump apex); mixer guidance teaches per-tick calls derived from getState()/isGrounded() ("identical calls are free") instead of script-side caches, and timers ride game time, never wall clocks.
  • Behavior-watchdog budget parks re-validate their earn-time safety gate while held (behavior-watchdog.ts, ledger #580 round 2). A park earned while an entity was server-only simulated (pre-mount) used to survive transferControl: the mounted car's update() — the ONLY writer of a generated vehicle's wheel control lanes — stayed skipped, so held W landed in state via onInput and died there, leaving the rest-slept chassis frozen at 0.00m while mount/camera/toast all worked (both round-1 sim-side self-heals read lanes/rows that only update() writes, so they saw zeros by construction). isBudgetParked now drops an entry whose entity has an active TomeController or is otherwise no longer one-sided-safe to park, and the earn gate (canPark) refuses actively controlled entities in singleplayer too (multiplayer already refused them via isServerOnlySimulated). Cost: paid only by entities carrying a live park (size-0 early-out unchanged); zero physics-path changes, so #416 rest-sleep for idle parked/mounted vehicles is untouched.
  • Budget parks are now Datadog-visible: one tome.behavior.budget_parked warn per park event (≤ once per 10s cooldown per scope) beside the existing in-room runtime-log breadcrumb. Round 2's gate ambiguity — a clean DD window that proved nothing about parks — was this exact hole.
  • New integration pin vehicle-sleep-mounted-input-wake.test.ts: live multiplayer server stack, real netcode join (spawnPlayer), a REAL watchdog park earned by a busy pre-mount update(), rest-sleep, mount via interact input frame → interaction-dispatch → transferControl, then 130 ticks of held forward frames through tomeInputApplier's control-target reroute — asserts the car wakes and drives (red on the unfixed build with the field's exact 0.00m signature).
›migration notes

Scripts that relied on builtin/combat's damage() writing dead: true into the TARGET's state on the killing blow must now detect death themselves: if (s.health <= 0 && !s.dead) { api.patchState({ dead: true }); /* death FX, respawn */ } — which is exactly the pattern the combat skill has always taught (and which the old behavior silently broke). isDead(api, id) is unaffected: it has always treated health <= 0 as dead regardless of the flag.

Nothing required. Heightmap terrains with pbr: true materials render matte natural ground by default — this is a default-look change for every existing pbr terrain, in the matte direction. An explicit roughnessIntensity still wins: 1 keeps the derived map as-is, below 1 stays the same polish/wet behavior as before; values between 1 and 2 now ease toward fully matte instead of multiplying (they read more matte than before, matching what the knob's docs promised). TASTE FLAG: the curve fix is knob honesty (bug — "toward 2 for matte" could never reach matte), but the 1.7 default value is a taste proposal — Jacob tastes on stage before promote.

Taste change, ratified by Jacob (2026-06-10: "the physics scale of each item should be a bit larger than it is today to achieve better fill rates"): terrain decoration sprite/grass cards that omit height now default to [0.45, 0.75] world units (was [0.3, 0.5]) — existing worlds' unsized decoration cards render ~50% larger, covering more ground per instance. Specs that author height explicitly are unaffected. If a game relied on the old default card size, author height: [0.3, 0.5] on the item.

Behavior changes only for games whose terrain is ALREADY broken today (rendering manufactured fallback mountains instead of what their creator wrote):

  • A generator authored with the positional shape (heightAt(x, z) / heightAt(x, z, ctx), same for materialAt/tileAt) currently throws on every sample and renders seeded fallback mountains spanning the whole vertical range. After this version it renders the AUTHORED terrain. Terrain-relative anchors re-resolve against the new ground on next spec apply; literal-y content keeps its y and may sit high/low relative to the corrected ground (same class as any terrain fix — the engine deliberately never re-snaps literal y values, intent unknown).
  • A generator that compiles but throws or returns non-finite per sample (and isn't the arity class) currently renders those mountains too; after this version it renders FLAT ground (0 clamped into the vertical range) plus a terrain-generator-runtime-error diagnostic. Content authored against the fantasy mountains will sit on flat ground — that is the honest state, and the diagnostic tells the creator why.

No change for any game whose generator runs clean, for the engine default terrain (its procedural module is healthy and keeps its exact shape), or for definitions with no generator source at all (the deliberate procedural pathway is untouched).

None. No authored values change meaning; no defaults move. SkyEnvironment.update now takes the frame's deltaSeconds (internal renderer API, one call site).

Breaking for @spawnco/server / @spawnco/sdk-types npm consumers: gameSpec.latest() no longer returns null on 404 — it returns { notFound: true, reason }, which is truthy. External server code using if (!result) to detect a missing spec must switch to if (result?.notFound) (and read result.gameSpec only when notFound is absent). In-repo call sites are migrated; the next npm publish must version this as breaking.