engine v5.0.13
Engine v5.0.13
June 12, 2026
A patch in the For Real line.
what's new
- Boardwalks built down slopes with tight switchback corners now render coherently — no more railing bars shooting past hairpin turns or flickering planks on long straight runs.
- Savi editing scripts while you play no longer makes the world's grass and decorations flash and hitch — the scatter only rebuilds when you actually change the decorations.
- Calling an ObjectAPI method that doesn't exist — whether it was removed in an engine update (like
api.patch, removed in 4.5.2) or never existed at all (likeapi.removeObject) — now tells you the real method to use instead of a bare "is not a function". - When a script calls an old removed API name, the error now tells you the new name to use instead of a bare "is not a function".
- The breeze is back! Grass, flowers, and reeds with wind on them sway again — bases planted, tips swirling in the wind you authored.
- Screenshots no longer hitch the game: when Savi peeks at your world, a thumbnail gets captured, or you snap a shot for chat, the frame used to stall for ~8 frames mid-gameplay. Captures are now visually identical and nearly free.
- Platformer fix: characters now fall off platform edges cleanly instead of stuttering up and down at the lip. Walking down slopes and stairs still feels glued, exactly like before.
- A spotlight given a too-big cone angle (like an angle in degrees) used to go completely invisible with no error. Now it lights up at its widest cone instead, and Savi gets a log telling her exactly how to convert the value to radians.
- Games with rich terrain no longer gray-screen on GPUs with strict texture limits — the engine now simplifies terrain shading on those machines instead of failing to draw the world.
- Placing objects on the room grid now works in every spawn shape the docs show:
spawn({ properties: { feetPosition: { tile: [x, y] } } })(with optionaloffset) lands on the tile like the 3d-rooms guide says, including inline children. Mistyped tile positions get precise messages instead of a generic rejection, and using a tile position in a world without rooms terrain tells you why the object landed at the origin. - Walking through translucent things — light shafts, ghosts, holograms, glass effects — no longer slices the screen with hard edges. They now dissolve gracefully as you get close, exactly like you'd expect.
›technical notes
- Spline corner joins now apply a miter limit (
SPLINE_CORNER_MITER_LIMIT_RATIO = 2): the exact offset-line intersection is kept for turns up to ~127°, sharper corners fall back to a bevel. Pre-fix, hairpin corners extended laterally-offset bars (boardwalk/conveyor rails, corner fill polygons, stair corner landings) byoffset * tan(turn/2)— unbounded, e.g. ~2.6m floating rail spears on a switchback boardwalk. - Hard-corner boardwalk segments no longer emit duplicate plank/rail-post/pile boxes at collinear subdivision boundaries (exact coincident twins that z-fought).
- Fixed the live-edit decoration flash (dump 97f8b3c0):
setTerrainDecorationConfignow content-guards at the renderer boundary — a re-emittedterrain/decorationsop whose config is structurally unchanged keeps its revision instead of bumping it, so the scatter rebuild guard insyncTerrainDecorationsstays satisfied and the full teardown (clearEntries + synchronous material compile) only runs on real config changes. The compare is a structural walk over the modest JSON-shaped config (key-order-insensitive,undefined-valued keys count as absent), run once per decorations op, never per frame. Real changes — layer add/remove, item edits, budget/settings changes — rebuild byte-identically to before. - Classified
tome/resim-telemetry-emitteraslive-channelinresource-dispositions.ts. The resource is new in 5.0.12 (fleet resim telemetry) and was hitting the exec snapshot builder's plain-data gate ~30x/hr in prod ("clone disposition but failed the plain-data gate (function value)") — the same post-audit class astome/scratch(#7143): it landed after the #7057 declared-type sweep, and the value is a room-runtime logger closure (the spec-push-notifier pattern). Behavior is unchanged — the gate was already shipping it as absent, and absent matches the resource's own contract: it's installed by the server room runtime, absent on clients and singleplayer glue, andresimTelemetrySystemprobes-and-degrades (no emitter → bail before touching state). The live realm reinstalls it at room init; nothing about it can or should ride a snapshot back. - Sibling sweep of every resource registered between the 5.0.11 and 5.0.12 mints:
tome/runtime-cursor-override(boolean),tome/sound-durations/tome/sound-duration-reoffers/tome/pending-sound-ends(plain Maps/arrays of scalars) are all plain data and correctly default toclone; no other function-valued resource is unclassified. The dispositions test's audit list gains the emitter, and a snapshot round-trip test pins no-warning + absent + system-no-op-after-restore with the closure-bearing emitter installed. - Classified
tome/scratchasre-deriveinresource-dispositions.ts. It was the remaining resource hitting the exec snapshot builder's plain-data gate in prod ("clone disposition but failed the plain-data gate (function value)" — the #7057 sweep audited by declared type, andTomeScratchStatedeclares plain Maps/Records, but arena values are script-owned and games stash closures in them at runtime). Behavior is unchanged — the gate was already shipping it as absent, and absent matches scratch's own contract: arenas are realm-local working state,ensureScratchStatelazily re-creates empty state on first worker access (scratch.ts documents lazy init as its restore path), and scratch writes are definitionally non-transactional so they never ride the merge log back. This makes absent-by-design explicit and silences the per-dispatch error log. - Prod log audit of the gate-failure pattern (7 days): only
tome/quality-landing-reset-broadcaster(fixed in #7057, noise continues from pinned pre-#7057 engine versions) andtome/scratch(this change). The dispositions test's audit list gainstome/scratch, and a snapshot round-trip test pins the no-warning + absent + worker-side-fresh-arena behavior. - The removed-API tombstones (
src/tome/api/removed-api-tombstones.ts) now also cover the top dead ObjectAPI names from prod failure mining (~1,100 calls/week T7d):patch(×443),patchObject(×173),removeObject(×156),patchPlace(×88),despawn(×86),setSpec(×76),updateObject(×73),patchSpec(×64),readScript(×15),readFile(×3). Calling one now throws a TypeError that leads with the name and gives the exact replacement call shape (e.g.api.removeObject was never an ObjectAPI method — use api.destroy(id); the setSpec/patchSpec family teaches the per-slice patch verbs;readScript/readFileteachgetScript(path)/listScripts()). All but one are PHANTOMS — methods that never existed — and their messages say "was never an ObjectAPI method", not "was removed": the message is the teaching, so it doesn't lie about history. The exception isapi.patch, which is real history: it shipped as the generic dot-path dispatcher in engines 4.4.0–4.5.1 (#6417) and was un-merged into the discrete patchX verbs in 4.5.2 (#6568) — prod apps pinned to those engines still run it today. Its message says "removed in 4.5.2" and teaches per intent: own state →patchState; other objects →setObjectProperty/batchSetObjectProperties/patchObjectState; spec slices → the per-slice patch verbs; dot-path spec writes → the matching slice verb. Same mechanics as the original tombstones: non-enumerable call-time throwers installed once per world prototype, no Proxy, zero new code on live call paths; every phantom was verified absent from the current api surface (the once-removed now-livepatch*slice methods stay live and unshadowed, guarded at world construction), and a new test asserts every verb a tombstone message teaches is itself a live method — a tombstone can never teach another phantom. Also fixed:updatePlace()'s non-keyed-map "objects" warning taughtspawnObject/removeObject/updateObject(two of which are phantoms tombstoned here); it now teachesspawn()/destroy()/setObjectProperty(). - Calling a removed ObjectAPI name (
raycastPhysics,raycastPhysicsAll,raycastPhysicsDown,getAimDirection,getPointerDirection,getPointerRay,getAimOrigin,directionFromYawPitch,rotationFromDirection,destroyObject,patchEphemeralState,setEphemeralState,replaceEphemeralState,defaultState) now throws a TypeError carrying the migration path from the consolidation changelog (e.g.api.getPointerRay was removed — the cursor ray is api.getInputRay(input), including themaxDistance → distance/ignoreIds → ignoreEntitiesoption renames for the raycast trio) instead of a bare "is not a function". Tombstones are non-enumerable call-time throwers installed once per world prototype (src/tome/api/removed-api-tombstones.ts) — no Proxy, no compat alias, zero new code on live method call paths. Both behavior hooks and run_script (including read-only) are covered; the enriched message flows through the existing behavior-fault DM unchanged. A construction-time guard throws if a tombstoned name ever returns as a live method. - Resimulation stats: the expired-sample sweep (
prune) on the record paths is amortized to once per second instead of running on everyrecord/recordPushDelivery/recordBaselineAdopt/recordResimDeferralcall (3.3% of sim busy during correction storms). Snapshot output is byte-identical — retention (2s) exceeds the reporting window (1s) andsnapshot()still prunes unconditionally before reading; pinned by test. - Restored terrain scatter wind displacement lost in the renderer deslop (#6517):
wind: { force, scale, speed }on grass/sprite decoration items sways the cards again. The GPU-scatter card material now installs anmx_noise_vec3displacement (instance-world-position seeded, time-scrolled, masked by normalized card height so the base stays pinned) whenever a usableforceis authored — windless items compile the exact same node chain as before, zero added vertex cost. Old defaults preserved:scale0.8,speed1.0,force0 (wind without a force does not move). Displacement is branchless WGSL (select()-lowering hazard) and pinned by a dominance-analyzer suite. - Viewport screenshot capture (
captureRendererScreenshot) downscales BEFORE encoding instead of after: the frame's ImageBitmap is drawn onto an OffscreenCanvas capped at 1280px on the long edge and encoded as JPEG q0.92, replacing the synchronous full-resolution PNG encode that ran on the render worker (trace-proven at ~137ms / ~8 dropped frames per capture on a retina viewport, fired by every chat/Savi/SEO screenshot). Every consumer already downscaled to ≤1280px JPEG on the main thread — chat 400px q0.5, scene views 768px q0.7, SEO/savi-note thumbnails 1280px q0.92 — so final outputs are unchanged while the hot-thread encode runs on ~10-20x fewer pixels with a cheaper codec (and the consumers' own decode gets equally cheaper). The sizing/format decision is a pure exported function (resolveScreenshotEncode); the 2D/viewport scene-view path reports the encoded dimensions. - Character controllers (rapier + mantle) no longer jitter at platform edges. Walking off an edge, snap-to-ground (a full-capsule shapecast down) re-caught the platform lip while only the capsule rim still overlapped it: grounded=true zeroed the motor's accumulated fall velocity, one gravity quantum followed, and the snap caught again — a self-sustaining loop (every catch re-armed the snap grace window) that held the character hovering at the lip in 1-frame gravity quanta instead of falling. Both controllers now require support under the capsule AXIS (an inner-radius down-probe within snap reach) before a snap commits or a contact classifies as ground; a rim-only lip catch reads NOT grounded and never re-arms the snap grace. Slope descent, stair descent, step-downs within
snapToGroundDistance, and flat-ground walking are unchanged (the probe hits the surface that continues under the axis) and pinned by parity tests in both engines, 3d and 2d-side. No new cross-tick state: the probe is a pure function of position + world geometry, so prediction/resim determinism is untouched. - Standing astride a seam or gap (two platforms, tops level, character axis over the split) stays grounded. The axis probe alone demoted that stance to airborne forever — jump denied once coyote drained, air-pose while standing, NPC isGrounded deadlock, ride-velocity cut on seamed moving platforms, and unbounded phantom fall-velocity accumulation. The rapier probe now has lateral width (offset rays at mantle's thin-capsule ratio, so centimeter seams between abutting blocks can't be threaded) plus a straddle probe (rim-height support on BOTH sides of the footprint counts; one-sided rim support still demotes, so the lip fix is intact). Mantle falls through to its wedge test when ≥2 opposing bottom contacts exist — opposing rim contacts that block descent ARE support — with the down-probe run in the support's reference frame so descending platforms keep their riders.
- Spot light
angleis now clamped to THREE's defined cone domain (0, π/2] at the engine's singleTHREE.SpotLight.anglewrite (setSpotLight). Above π/2 the cos-based smoothstep cone edges invert and BOTH lighting paths (clustered packer + legacy dynamic) render exactly zero light inside the cone — the prod invisible-flashlight class, where a degrees-shapedangle: 26silently killed a creator's spotlight across 9 spec versions. Degrees-shaped values (> π/2) additionally report once per light on the engine diagnostic rail (light-spot-angle-clamped, getLogs + one-time DM) with the copy-pasteable radians fix; non-positive angles clamp to the 0.0001 epsilon floor (THREE's smoothstep degenerates at exactly 0) without the degrees message. Authored spec values stay untouched — the clamp is render-side only. The api-reference skill now states radians explicitly on the LightSpec line, the spotLight builtin, and the spot example. - Gray-screen class fix (partner report, game "Higher"): a terrain pool material whose pipeline the preflight resource guard rejects (granted
maxSampledTexturesPerShaderStageexceeded — the WebGPU spec minimum is 16) no longer aborts every frame forever. The renderer walks a texture-budget ladder (degradeTerrainTextureBudget): rung 1 rebuilds every pool on the simplified lit variant (LOD>=2 resolve, PBR/NRO compiled out — 3 fragment-stage bindings), rung 2 on the unlit floor (material.lights = false— no cluster/shadow/IBL taps at all). Scene-level rung; pools created later inherit it. Each rung reports through the engine diagnostic rail. - Non-terrain materials that exceed pipeline resource limits are now hidden (
object.visible = false) instead of re-throwing per frame, so one over-budget material can no longer blank the whole scene. - New suite pins the terrain material's per-stage sampled-texture count by compiling the real WGSL: a maximal library (PBR + NRO + noise + gradient + height tint + emissive) binds exactly 5 fragment-stage textures, and the worst-case lit stack stays ≤ 16 on every tier.
spawn()'s feetPosition validator now admits the{ tile: [x, y], offset?: [x, y, z] }position shape thatresolveRoomsTilePositionalready resolves and the 3d-rooms skill teaches — previously the gate layer rejected the shape while the writer layer fully supported it, failing whole spawn batches in room worlds. Tile coords must be two integers (the ASCII-grid indices the resolver looks up; fractional coords would silently skip the floor-height lookup),offsetmust be[x, y, z]finite numbers, and stray keys reject by name. Covers every spawn shape: directspawn(), inlinechildrenrecursion, andsetProperty("feetPosition", ...).- Tile positions in a place WITHOUT 3d-rooms terrain still resolve to the origin (the fallback is load-bearing — interpreter phases must never see a throw), but no longer silently: a teaching warn lands in the runtime log, once per place per world.
spawnFxanddamageNumber— the two position entry points where tile positions genuinely can't resolve (they take raw world points) — now say so explicitly when handed a{ tile }shape, instead of a generic shape rejection.- Mesh materials in the ghost/shaft class (
transparent && depthWrite:false, depth-tested) now compile a camera-proximity alpha fade: fragment alpha ramps to 0 by 0.5m from the camera and is EXACTLY 1 at/beyond 1.5m (WGSLsmoothstep— far-field byte-identical). Fixes the near-plane degenerate case for creator-built god-ray shells / ghost meshes: the shell wall no longer sweeps the screen as a hard-edged clipped polygon when the camera enters it, and stacked-shell brightness no longer pops stepwise wall-by-wall (each wall now dissolves through the ramp). Applied on both construction paths: standalone mesh node materials (createMeshNodeMaterial/...FromSource, re-settled after GLTF source flag copy) and the transparent non-overlay primitive batch lanes. Opaque,depthWrite:true,depthTest:false(overlay), and appearance-owning (water/shockwave/slash/scripted) materials keep node graphs untouched. Fade is alpha-only (no emissive hue shift), branchless TSL (noselect()/toVar— r0.184 lowering hazard), and dominance-analyzer pinned. - Scene-depth ("soft particle") intersection fade deliberately not built: the only scene depth reachable from a scene-pass material is
viewportDepthTexture, whose mid-pass framebuffer grab forces the MSAA store every frame such a material is alive (viewport-share, ledger #253/#332) — a standing perf tax on every world containing a ghost mesh. Hard floor/prop intersection seams at a distance are unchanged.