engine v5.0.9
Engine v5.0.9
June 9, 2026
A patch in the For Real line.
what's new
- 3D models are now automatically optimized for better performance, with additional simpler versions used as they move farther away.
- Objects built from connected moving parts now stay together during movement and animation.
- Fixed a bug where players could repeatedly fall through solid terrain instead of being placed safely back on the ground.
- Fixed a bug where heavy terrain edits could leave the ground non-solid on the server — players fell forever while the world looked normal. The engine now detects and repairs missing ground under players automatically, and reports it instead of failing silently.
- Fixed a bug where leaving god mode could leave your character deaf to controls — interactions dead and jumps snapping you back to spawn until a refresh.
- Fixed god-mode edits in busy worlds appearing to fail with a 30-second hang — saves now confirm immediately.
- You can put the game in a box now: build a HUD around it, shrink it into a panel, letterbox it to a fixed shape — just drop
<spawn-canvas>into your UI and style it like any other element - Card games, strategy views, retro consoles-in-a-frame: the 3D view is finally just another piece of your layout
- Things can jiggle now! Set
jiggle: trueon any object — slimes squash when they land, tails and ears sway and whip on characters with dangly bones, signs wobble on their posts. Tune it withamount,bounce, andgravity. - Games with HUGE crowds are now real: armies, swarms, flocks of thousands of units that all think and move every tick
- A runaway script can no longer freeze your game — the engine benches it for a few seconds and tells Savi exactly what was slow
- Attach things to the right PART of a model —
attachment: { socket: "muzzle" }puts a flash on the gun barrel, a hat on the head, a rider in the saddle. Savi names the spot; the engine finds it on the actual 3D model. - New
getSocketlets scripts aim from those spots too — spawn projectiles from the muzzle, not from the object's feet. - Shadows no longer pop in and out when you turn the camera: objects behind or beside your view — walls, pillars, props, characters — keep casting their shadows into the scene.
- Point and spot light shadows look noticeably sharper and softer-edged: shadow maps now use the free space in the shadow atlas instead of leaving it idle, and shadow edges get a proper soft falloff instead of a hard stair-step.
- Preview thumbnails while a model generates now show a clean cutout of the object. White-ish objects no longer turn semi-transparent in the preview, and previews appear several seconds sooner.
›technical notes
Exec can no longer monopolize the sim tick loop (ledger #376). A wisp's 30-second room.exec froze a live prod room's simulation for the full 30s — tome/script-dispatch runs each queued script synchronously inside the simulation phase, on the same thread as the tick pump (runtime-worker's setInterval), so one long script pinned every tick, players disconnected, and the recovery fed ledger #333's cap-adopt storm. Two budgets now bound what the engine controls: (1) the per-tick drain is time-boxed — each script still commits transactionally within its tick, but another queued script only starts while inside the slice budget, so a pile-up of slow execs spreads across ticks instead of compounding into one frozen mega-tick (at least one script per tick, no starvation); (2) each script gets a synchronous execution ceiling (5s) checked on every sandbox call into the engine (api.*, bare helpers, require) and at the result boundary — past it the exec aborts, the transaction rolls back, and the caller gets a loud "split the work into smaller run_script calls" error, even when user JS catches the abort. Parity-safe: only the authority realm executes a script and its effects ride the wire, so budgeting changes which tick a script runs, never side-local state. Residual, documented in-code: a pure-JS busy loop that never calls back into the engine still pins the thread for its duration — in-thread preemption of synchronous user JS is impossible.
- Kiln's model postprocess pipeline now sends supported generated and uploaded GLBs through the Rust/wgpu remesher, which selects a triangle target, rebuilds UVs, rebakes material maps, and emits native LOD1-3 variants for static opaque assets. Unsupported inputs and worker failures fail soft to the original asset.
- Static model batches now pool materials by model ID, LOD, and material signature instead of sharing one material across every resident LOD. Each remeshed LOD can therefore use its own baked textures and material state without contaminating another variant during automatic or explicit LOD switches.
- The documented
lodOverriderange now matches the emitted base plus LOD1-3 asset set. - Fixed renderer snapshot composition for deeply nested parent-child rigs by interpolating authored local transforms and composing them against the displayed parent pose.
- Fixed terrain fall-through rescue leaving physics transforms internally inconsistent: the post-physics rescue updated
WorldFeetPositionbut leftBodyPositionunchanged, so Rapier's external-transform sync and Mantle's center-pose sync could consume different poses and re-drop the entity. Rescues now derive the matching body center from the rescued feet, collider geometry, scale, and world rotation so both backends share one coherent pose on the next step (terrain-systems-shared.ts). - Fixed the server-plane terrain collider gap (ledger #381, the #285 invariant extended to the server spec-apply/patchTerrain path): heavy terrain churn (generator swaps, reseeds, repeated patchTerrain across an engine upgrade) could leave a chunk's
TerrainChunkBuildState"done" with a still-matching inputsHash while its collider plane (PhysicsBodyConfig + TerrainChunkColliderPayload + TerrainChunkCollider) was stripped or stale. The server build system dismissed every dirty mark on such a chunk as spurious, and the physics-step presence-parity check could not see it (it requires a PhysicsBodyConfig claim) — the server character free-fell forever while the client rendered solid ground.submitServerBuildCandidatesnow verifies the collider plane before dismissing a hash-matching mark and rebuilds in place on violation, without releasing surviving artifacts during the rebuild window (isServerTerrainColliderPlaneIntact,server-terrain-system.ts). - Added the server anchor-chunk live-collider watchdog (
terrain/server-collider-watchdog,terrain-systems-shared.ts): every 150 ticks it re-derives the terrain gate's anchor chunk set and asserts each anchor chunk of a terrain place holds an intact collider plane or has a rebuild in flight. Violations persisting two sweeps fire a loud[terrain/server-collider-watchdog]console diagnostic plus a runtime-log entry (visible to Savi viagetLogs(), codeserver-terrain-collider-gap) and remediate through the ordinary dirty-mark pipeline. Also reports the lost-definition class: spec declares terrain for an occupied place but no server terrain definition is installed. Server-authored diagnostic — noALLOWED_DIAGNOSTIC_CODESentry involved. - Fixed server-side player input-application death after god mode (ledger #386, dump aa3bd931): god-mode exit is now one shared teardown (
tome/god-mode/session.ts) used by every exit path — the toggle, disconnect-during-god-mode (disconnectPlayer), world reset (resetTomeWorld), and a join reconcile inspawnPlayerthat reaps any god-mode wreckage keyed to the joining player id. Previously a disconnect left the hidden god entity and itsTomeControllerhalf alive, and a reset leftTomeGodModeon the player with a dead god entity (api.isGodMode()stuck true), both of which made the body deaf to input for the rest of the room's life. - Control mappings resolve toward "no control" instead of resurrecting:
TomeControlTarget(controller side) is the authority andTomeControllera derived back-pointer —getControllerIdno longer re-creates a controller'sTomeControlTargetfrom a stale back-pointer (the bridge that let a leftover god entity capture a fresh player's input), it clears the stale index on the authority. - Added an authority-side input-routing liveness guard in the dispatch path (
tome/input-liveness.ts): a control target pointing at a god entity without a session, or a god-mode session without a god entity, heals the same tick (clear + full session exit, this tick's input reaches the body); a redirect whose dispatch chain dead-ends (no behavior / unresolvable ref / faulted) auto-clears after a 10s dead window. Every heal is loud — Datadog log, runtime log (Savi'sgetLogs), and a deduped DM. Working redirects (driven vehicles, controlled NPCs) are never touched; the multiplayer client never mutates routing state side-locally. - Made input-application liveness dump-visible (ties ledgers 135/315): the perf-rollup
modeblock now carriesinputRouting(local control target, god-mode presence, seconds since the body's last behavior input dispatch), and the kiln dump summary renders it as theInput:line in the Session block — a dump can now answer "did this player's body receive input dispatches" directly instead of inferring fromsrv=undefring rows. - Rooms now announce their identity (
x-spawn-room-id) on SDK spec-mutation persists (@spawnco/servergameSpec.applyMutations), so kiln can leave the authoring room out of the post-save fanout poke. The echo poke used to park at the author'spendingPersistscausal barrier — which was waiting on the very persist response kiln was holding while it awaited the poke — a three-party circular wait broken only by the 30s update-RPC timeout (ledger #352). Kiln's side (deferring the fanout off the response path, skipping the author on non-replace pokes) ships independently; old engines that don't send the header keep full-fanout behavior and are still fixed by the deferral alone. Replace resyncs (rejected batches, ledger #326) always reach the author regardless of the header. - Added the
<spawn-canvas>UI slot: placing the element in aspec.ui.renderHTML layout makes the game canvas track that element's rect (position/size/border-radius) instead of filling the viewport;RenderSurfaceSizederives from the slot rect × dpr. No slot → fullscreen, byte-identical to prior behavior - Canvas-relative input remap across every consumer: pointer NDC (
clientToCanvasNdc), world-pointer gating (presses only land over the slot), pointer-lock prompt rect, touch controls anchor to the slot, screen-space juice FX (letterbox/vignette/flash) re-anchor; pointer-raycast and pointer NDC freeze the last in-slot ray when the cursor leaves the window - Slot is
pointer-events:none(forced); transparent backdrop so chrome composites over it; one slot per layout (extras + transformed/clipped ancestors report a UI fault, no crash) - Jiggle physics:
jiggle: true(or{ amount, bounce, gravity, bones }, all knobs 0..1,nullremoves) — spring-damper secondary motion as one object property. Two renderer-side tiers picked automatically: bone tier (Dynamic-Bone-style spring chains on skinned rigs, auto-detected dangly bone names liketail/ear/hair/antenna, runs post-mixer/post-IK, rotation-only, length-derived pendulum frequency, gravity droop, swing/stretch clamps, animated-baseline restore) and object tier (whole-object lean + volume-preserving squash-and-stretch composed into presentation op values insynthetic-transform-delta). - All motion state lives renderer-side — the sim never reads a jiggled transform; nothing to mismatch, nothing to resim. Config is one replicated component (
draw/jiggle). Object-tier excitation is arrival-sampled from the snapshot ring's authoritative arrivals and dt-scaled: feel is frame-rate and tick-rate independent, springs rest at constant velocity, squash gates on uprightness. The bone tier claims the entity so characters never double-jiggle. - Jiggling models demote from horde batching (baked GPU palettes can't bend). Object-tier jiggle applies to world-timeline entities; hierarchy children whose presentation is parent-relative don't wobble from the parent's motion (the carrier wobbles, the cargo rides it).
- New closed-form damped harmonic oscillator core (
stepSpringDamper/stepSpringDamperVec3) — unconditionally stable at any dt. - Getter returns effective knobs (
jiggle: truereads back as{ amount: 0.5, bounce: 0.5, gravity: 0.3 }). Schema agrees with the live normalizer and acceptsnull. - Added the scripted-systems bulk verbs to ObjectAPI:
query({ select: "ids" })(bare-id queries with zero per-match materialization, also on CameraAPI),readPositions(ids, out?)(SoA bulk feet-position read, NaN-filled for missing ids),setPositions(ids, xyz)(bulk feet-position write with octree reinserts + physics pose sync deferred to batch end), andscratch(key?)(server-side, never-replicated working memory that survives script recompiles, decays after ~30s idle) - Added the behavior watchdog: authority-side per-tick wall-clock budget (half a tick, clamped 8–100ms); 3 strictly-consecutive over-budget ticks park a server-only-simulated entity's update() for a 10s cooldown (auto-unparks; onInteract keeps running; never rides the durable fault ledger)
- query() internals refactored around a single emit-core; existing query behavior byte-identical (path heuristics, stats, validation asymmetries pinned by tests)
- syncFeetPositionPhysics gained a no-body early-out; missing-target warnings now name the calling method
- New skill
scripted-systems(manager pattern canon, the three verbs, chunked spawning, scratch-vs-state law) + SWARM example; zoo gains a Swarm place; bench: scripts/bench-scripted-systems.ts (5k units: 10.2 → 7.5 ms/tick vs legacy path, query-side allocations eliminated) - Semantic sockets:
attachment: { socket: "muzzle" }on a parented object anchors it at a named point on the parent's model, resolved by a MagicCDN vision pipeline (multi-view VLM pointing + verification on the GLB). Resolution is async: unresolved children sit at the parent origin and snap to the anchor when the socket metadata lands; the engine warns through getLogs if a socket stays unresolved past the backoff ramp. - Resolution rails follow the bounds-prefetch pattern: the authoritative world polls MagicCDN's socket JSON (
?socket=<name>, no auth) and writesspec.assets.metadata[modelId].sockets[name]; clients warm generation through the player's cookie session. Socket entries replicate with the spec and persist viapatchAssets. - Socket anchors compose identically in the authoritative and render hierarchy solves (entity-local offset x parent GeometryScale, position lifted by
groundOffsetYso it's mesh-relative, not feet-relative). Bone-attached subtrees keep renderer-owned posing; sockets inside them ride the live bone transform. - New ObjectAPI getters:
getSocket(name)/getObjectSocket(id, name)→{ position, confidence } | { resolving: true } | null, world-space from replicated data only — deterministic across realms. Unresolved lookups kick off resolution and report{ resolving: true }. Bone-attached objects answer null (no live bone transform on the server). composeHierarchyTransformgains an optionalparentLocalAnchorparameter (joins after the pivot fold, before parent scale/rotate/translate). Socket-attached children are excluded from the synthetic rel-space presentation compose, same family as the bone exclusion — their drawn pose keeps the server-composed anchor.- Fixed shadow popping from camera-frustum instance culling: the instance packers (static model batches, primitive batch lanes / oversized pools, skinned hordes) culled instances against the bare camera frustum, and since the packed buffers feed the shadow passes too, an off-screen caster's shadow vanished the moment the camera turned away from it. Shadow-casting slots now cull against the camera frustum swept along the sun's travel direction (
sphereCulledBySweptFrustum+FrustumSnapshot.sweep,handlers/cull.ts): per-plane the swept-segment test is exact, and the light sync publishes the sun direction each frame (three/lights.ts). The 200 m sweep borrowsCASCADE_LIGHT_MARGINas a heuristic anchored at the camera frustum — a deliberate keep-rate trade documented at the constant (CSM's own caster clip is anchored at the cascade box; a tall caster slightly past the sweep can still render into a far cascade at low sun). The snapshot revision tracks material sun moves (>~0.6°) so packs re-evaluate; sub-degree drift keeps the cache. Sprites (never cast) and transparent-only batches skip the sweep; the GPU decoration compute cull is a named follow-up. - Local (point/spot) shadow-atlas sampling upgraded from a single
textureSampleCompareLeveltap to a 4-tap rotated-grid PCF kernel (ClusteredLightDataNode._setupAtlasShadow), gated behindIf(inFrustum)so cone-exterior fragments pay nothing. All four taps sample the same texture UUID — binding/sampler counts and shader structure unchanged. The normal-bias law follows the kernel:ATLAS_NORMAL_BIAS_TEXEL_SCALE1.5 → 2.5 andATLAS_NORMAL_BIAS_MAX0.5 → 0.85, covering the off-center taps' slope-proportional depth error out to the kernel's ~1.5-texel worst-case reach. - Shadow-atlas headroom promotion (
ShadowAtlasScheduler): when the whole candidate set, priced one coverage bucket up, would keep promoted demand ≤ 55% of the atlas, every slot gets that extra bucket (never past the tier'smaxSlotSize). Demand prices every candidate at its promoted coverage size regardless of eligibility, so frustum crossings, distance-fade, and adaptive shadow-distance steps cannot swing it; transitions additionally require a dwell (enter ~4 s, exit ~0.2 s) so spawn/despawn cycles can never flap the bonus. A promotion the buddy allocator cannot satisfy records a denial keyed to the allocator's release epoch — the slot holds its fallback cell with a live shadow instead of churning acquire/release at fade 0, and retries once space actually coalesces. Dead buttons from a collapsed input lead now self-heal, and Savi gets told (ledger #406, dump 8966b5d8).
A client whose tick lead collapsed (page refresh resuming into a long-lived room) kept sending one input frame per tick, but every frame landed for a tick the server had already consumed. The buffer accepted each frame, then swept it too-late — so the server applied ZERO inputs (every ack absent), every button/key in the game went dead, and nothing faulted anywhere. The state was stable: the late frame sitting in the pre-consume buffer made the throttle read "frames are flowing, nothing to speed up" (bufferedFrames === 0 gated the missed-input rail) AND reset the missed streak, so the speed-up that would close the gap never fired. Creator enfeul lost 88 minutes to this while Savi rewrote healthy scripts — from her side every probe showed correct authoring, because the only evidence (per-tick ack inputKind) lives client-side where she has no eyes.
Three pieces:
- Input buffer health now reports
lateCount(frames dropped too-late this tick, insert-late + burst-sweep) — the discriminator between "client behind" (late frames: speed up hard) and "client ahead with a gap" (future frames buffered: leave it alone). - The throttle treats a chronically-late stream as sustained speed-up pressure instead of a dead zone. A collapsed lead now closes at the 1.1x ceiling in a few seconds via the existing flow-control wire — no protocol change.
- Client-side starvation diagnostic (
input-pipe-starved, prediction/input-starvation.ts): ~5s ofabsentacks while frames are flushing — i.e. the self-heal did NOT work — emits one engine diagnostic per episode through the same rail asrenderer-worker-silent: runtime log (Savi'sgetLogs) + one DM, explicitly telling her the scripts are not the cause and what to do. Reports ride the command outbox, so they deliver even while input is starved. - Model-placeholder preview billboards no longer shader-white-key the preview texture (
createPlaceholderMaterialinthree/extensions/models/placeholder-visuals.ts). MagicCDN now removes the preview background server-side (BiRefNet) before writingpreview_image_url, so the texture's own alpha is the mask. The luma+saturationwhiteMaskheuristic — which also ate near-white, low-saturation pixels of the actual subject (white swords, paper, bones) — is deleted;previewAlphais justpreview.a.