engine v5.2.14
Engine v5.2.14
August 3, 2026
A patch in the Lume line.
what's new
- Safari no longer eats all your memory while a game sits waiting for its first click — the bug that could freeze a whole laptop on a quiet world is gone.
- The erase brush now erases every painted ground material at once — one pass returns the ground to its natural look instead of only removing the material you happen to have selected (paths and roads stay).
- Material brushes have a new Replace mode next to Paint and Erase: painting in Replace makes your material win by pushing the other painted materials out from under the stroke, instead of blending in on top. Plain painting is unchanged — it still layers additively, and brush strength still decides how strongly you cover the world's natural ground.
- Savi gets interrupted less while you build. Routine engine notices — a texture that didn't load, a clip name that doesn't exist, a placeholder holding while art generates — now go quietly into her logs instead of pinging her mid-conversation. She still hears immediately about real breakage (broken scripts, crashes, things that stop rendering), and about any problem that suddenly happens dozens of times at once.
- The stray scrollbar that could pop over your game's right edge (and make your game UI look broken) while art was spawning is gone.
- Savi stops getting blamed for axes the starter template ships. Every input change in a 3D game used to print five warnings telling her to remove
lookX/aimYawSin-family entries that the platform itself put in the game — noise that could bury the real error when something was actually wrong. Those entries are now recognized as the platform's own, new games no longer include them, and the warning only fires for something that can genuinely break: binding a key or stick to an axis the engine computes from the camera. - Placeholder images keep their shape while models generate — a tall character concept no longer renders squashed and stretched on its loading card.
- The touch stick on phones no longer freezes and vanishes when you move your thumb slightly. In games with on-screen UI, the browser could steal a touch a few pixels into a drag (it read the drag as a page-scroll attempt), which killed the movement stick, camera drags, and hand-rolled touch pads mid-gesture. Touches on your game now belong to your game from press to release — and your scrollable panels and lists still scroll exactly as before.
- Replace mode now does what its name says: painting in Replace pushes out the world's natural ground as well as other painted materials — a full-strength stroke leaves pure paint instead of a half-and-half blend. Erase still returns everything to natural, undo still takes the whole stroke back.
- Ground material transitions render crisper everywhere: where two materials meet you see the dominant one with organic speckle instead of a translucent wash.
- Terrain texture tiling now uses the same
textureScalemeaning as objects: meters each texture tile covers. If your world's ground suddenly looks ultra-dense, ask Savi to convert the old values (new = 2.5 / old). - Games with multiple areas now work in every kind of room — walk between places with friends
- Savi can make heavy visual effects phone-friendly automatically — ask her to tune your look for mobile
- Characters with a single animation now play it instead of freezing in a pose
- Firefox players: sessions no longer die after tab-switching
- Faster texture loading on big worlds
- Multiplayer worlds with lots of places run smoothly again — the server stopped simulating places your players were already running, which was dragging the whole room's tick rate down.
›technical notes
- AudioParam writes now go through a dedupe + suspend gate at the renderer seam (Safari P0, #w-rendering 2026-08-01). Every AudioParam write — including plain
.value =— inserts a WebKit timeline event (AudioParamTimeline::insertEvent, one heap allocation each); prep re-sends full state every frame (≥16 inserts/frame with zero voices, ~6 more per voice), and while the destination is autoplay-suspended nothing consumes the timeline — measured ~12 MB/s WebContent growth until the machine swaps to death. Now a write whose target equals the last value actually written is skipped (per-param last-written cache; epsilon for ramp targets), and whilectx.state !== "running"no per-frame param write lands at all — desired state parks and thestatechangeback to running flushes one converging refresh. Ramps still schedule normally while running (no zipper-crackle regression); bounded one-shot writes (node creation, cull fades, duck ramps, vibe edits) stay ungated. Pinned byrenderer-param-timeline.test.ts(red on the old renderer, 9/9 green now). - The erase brush clears EVERY painted material at once (jacob's punch list, #inventors 1785628548). Erase on a material brush was a sign flip on the armed field only — erasing "dirt" left sand and grass paint standing, so returning ground to its natural look took one erase pass per palette material. An erase dab (Alt, the Mode chip, or negative-strength
armBrush) now runs the paint script over every authoredterrain:material:*field in the place — enumerated from the fields that actually hold paint, so orphaned fields whose material left the palette clear too. Material fields ONLY: marks,terrain:height,decor:*, and the generator's natural weights are untouched — erase returns the texel to its natural generated look. The expansion is per-dab and never persisted (brush.fieldsstays the armed field); commit/undo bookkeeping now iterates the stroke's touched-field ledger, so one undo restores every field the erase cleared. - Replace mode: paint that wins. New third Mode on material brushes (chip: Paint / Replace / Erase; session flag
replace;armBrush({ replace: true })). A replace dab paints the armed field exactly like additive paint (max(current, amount),amount = falloff × strength) and additionally scales every sibling painted material field by(1 − min(1, amount))— engine math in the session, so the paint-script contract stays one-field and overridden material scripts inherit the mode. All siblings scale by the same factor (their relative mix is preserved as they fade); at strength 1 a single core dab zeroes them. Natural generator weights still compose underneath (chunk-build's additive law is unchanged), so strength remains the lever against natural coverage: replace-at-2 visibly dominates. Default stays additive — jacob ruled additive correct; replace is the opt-in. - Cursor label gains "Replacing"; the Mode chip label gains the same. Non-material brushes keep the two-option chip (no sibling family to lower).
- Pinned in
brush-material-erase-replace.test.ts: erase clears all painted material fields (armed-or-not) and leaves height/decor alone, emptied fields drop from the spec, one undo restores them all; replace scales siblings proportionally (1.0/0.5 → 0.5/0.25 at strength 0.5) and compounds across dabs; Alt-erase beats an armed replace; additive-without-the-option writes no sibling chunks at all; the chip index contract; thearmBrushsurface. - Engine diagnostics adopt jacob's DM taxonomy (#inventors 1785635891): everything in logs; errors and storms ping. Every client-reported diagnostic still lands in
getLogs()the moment it arrives (unchanged). Each of the 98 rail codes inengine-diagnostics.tsnow carries a class: error (broken/wedged/terminal — script compile/runtime faults, renderer crashes, exhausted self-heals, physics corpses, engine parks of authored content) DMs Savi once per episode on first occurrence, with repeats counted to the log; warning (renders degraded-but-playable, placeholder shows, self-heals, teaching, environmental — texture/model load failures, missing clips, slot exhaustions, quota parks, schema skew) never DMs per instance. Either class escalates ONE storm DM when a single code accumulates ≥25 reports inside a 5-minute window ("N reports of this kind… something systemic; getLogs has every instance"). The old trailing "+N more" rollup flush — one hidden Savi model turn per active code per window — is deleted, along with its timer machinery and budget-carry branches; the perf-pointer lane (#235 hourly categories) and the room-global 5-DM/min budget are untouched. Class table pinned inengine-diagnostics.test.ts. - The game document can no longer paint a scrollbar (tiger, #inventors 1785641498). The engine page's html/body had no overflow guard, so any in-flow element that outgrew 100svh by a few px — in tiger's session, a transient during an art cook, frame-coincident with the kiln "Spawning art" chip — painted a full-height document scrollbar over the game's right edge on always-visible-scrollbar setups, and
window.scrollTocould slide the whole canvas (measured: 8px in-flow overflow → scrollHeight 892 in an 884px viewport, scrollTop movable). Rootoverflow: clipnow propagates to the viewport ashidden(CSS Overflow §3.3): no document scrollbar, ever, regardless of what transient DOM appears. In-game scroll surfaces are untouched — realm UI already scrolls inside its ownoverflow: hiddenchrome-host container, and the kiln page's own scrollers (chat, settings) live in the parent document. Pinned bygame-document-overflow.test.ts. - The input-binding validator stops billing Savi for entries the platform wrote (r-20a666a8, ravi's 08-01 playtest dig 3c3d2509). Every 3D game minted from the starters carries
lookX/lookY/aimYawSin/aimYawCos/aimPitchNininputs.axes— the starter template ships them — and the #11518 validator flagged all five as"engine-internal axis … Remove this entry"on the merged map, so EVERYpatchInputs(including ones that never touched axes, including writes that later rolled back) drew 5 warning lines for entries Savi never wrote. During the 08-01 playtest the noise drowned the real schema errors in a 19-second terrain thrash. The condition was both provenance-blind and factually wrong for two of the five names. - lookX/lookY leave the scold entirely — they were never "engine-internal" in the claimed sense. They are binding-fed axes with an engine DEFAULT (
buildDefaultBindingsinjects the mouse binding only when undeclared — thehasOwnPropertyguard exists precisely so declarations override, and gamepad-look treats explicitrightStick → lookX/lookYbindings as the look gesture). Declarations now validate like any axis: a remap is silent, a junk source names itself, and an EMPTYlookX: {}— the one genuinely hazardous shape, since it suppresses the default and binds nothing — gets an honest teach ("replaces the engine's default mouse-look binding with nothing … remove the entry to restore the default") instead of a false one. - Engine-computed axes (
aim*,cam*,pointer*,touchActive) warn only on the genuinely-wrong shape: a declared hardware source.input-resolutionseeds each axis from the engine'ssetAxiswrite, then keyboard bindings override it and mouse/gamepad deltas add to it — soaimYawSin: { keys: "q/e" }really does fight the camera-derived value, and still warns (with the true mechanism named). A sourceless{}declaration compiles to nothing, blocks nothing, and is exactly what the platform's own templates planted — it is now silent everywhere (patchInputs rail and spec-sync console lane both ridecollectInputBindingFaults). - Multiplicity: one line per patch, never one per axis. All sourced engine-computed axes in a map collapse into a single fault line naming them together — the 5× per-write fanout shape is structurally gone.
- The planting stops (structural elimination). The five declarations are deleted from
packages/game-starters/starter-3d.ts, cf-kernel'sDEFAULT_GAME_SPEC, and kiln'sDEFAULT_ADVENTURE_GAME_SPEC(behavior-identical: the engine injects the same mouse-look binding when undeclared, and the aim basis never read declarations at all). The teaching corpus stops teaching the shape: the camera skills' "Required Inputs" JSON and the@tomeapi-examplecamera SPEC-REQUIREMENTS lines now say these axes are engine-provided reads, declared only to remap look. The starter drift fixture is regenerated in this commit. - Placeholder concept images keep their aspect (tiger's squashed shinobi, dump ad7dcd12). The mcdn placeholder ladder's image rung stretched the decoded preview across the full square hologram quad (
packPlaceholderspacks one scalar drawn size; UV spanned the whole quad), so a 1024×1536 portrait humanoid card rendered 1.5× too wide.packPlaceholdersnow packs a contain-fit rect (placeholderPreviewFit— the fitted rect as a per-axis fraction of the quad, computed from the decoded texture's own dims) into the instance's two free pad floats, and the preview shader letterboxes the image to that rect — centered horizontally, bottom edge at the entity's feet, fragments in the letterbox margins discarded exactly like the BiRefNet-cut alpha. Quad size, pick volumes, crossfade, and TAA velocity behavior are unchanged; procedural/failed instances keep byte-identical instance data. - World touches in the authored realm survive the browser's pan-slop — the mobile touch stick stops vanishing on slight movements (incident 300a3b65, Savi-filed; enigmatic1's report, 08-01). When a game has live authored UI, the realm frame iframe is the viewport-wide hit target for every touch, and a fall-through press (target
<body>/<html>— the pointer-events law) is the world's: the platform stick, look-drag, and world gestures. The frame document shipped with notouch-actionposture, so the browser's touch gesture disambiguator was free to claim those presses for a pan at its ~10px slop —pointerdown → pointercancel, nopointerup— and Chrome cancels even when nothing in the chain can actually scroll (browser-gate receipt: non-scrollable host, non-scrollable frame, micro-drag still cancelled). The arbiter then correctly ends the analog claim, so the stick died ~10px into every drag — forever under its own 8.16px deadzone, which is exactly Savi's filed observable (touchActive:1withmoveX:0/moveZ:0while the player holds the stick, "the stick widget vanishes when pressed or nudged slightly"). The shell now pinstouch-action: none+overscroll-behavior: noneon the frame'shtml/body: a world press can never start a browser pan, so its pointer stream stays the platform's from down to up. Same law, third layer — the canvas has carried it since day one (browser-host/dom.ts), kiln's game routes carry it (game.css), and the frame the UI actually lands touches on now does too. - Pinned by
tests/browser/suites/world-touch-stick.pw.ts(real shell + real frame bundle, CDP touch): the micro-drag world press deliversdown → moves → upwith zeropointercancelunder a scrollable ancestor chain AND when the frame's own root scroller has extent (body touches pan neither);mobile-authored-ui-actions.pw.ts's scroll-stays-scroll matrix pins that authored scrollers still really scroll (CDP flick moves#shop-list). Arbiter-level law pinned inarbiter.test.ts: an analog claim has no slop-cancel — sub-slop micro-moves plus a hold past every gesture window keep the claim alive to the lift. - Replace mode fully replaces — natural ground included (jacob's 50/50 crop, #inventors 1785628548). Replace previously zeroed painted siblings only; the generator's natural weights still composed underneath, so replace-at-1 over natural grass landed at 50/50 — "it's NOT winning it's merging". Natural coverage is derived (
materialAtre-evaluates per texel), so there was nothing stored to write down; the suppression now persists in its own channel,terrain:natural-mask(0–1). A replace dab raises it toamount(max-composed, clamped to 1) alongside the sibling scale; chunk-build scales the generator's material result by(1 − mask)BEFORE marks and paints compose — a full-strength core is pure painted material. An erase dab writes the mask back down (subtract, like the material script's signed erase), so one erase gesture returns replaced ground to its natural look and the emptied mask drops from the spec. The mask rides the stroke's touched-field ledger: one undo restores paint, siblings, and suppression together. Marks stay outside the mask — they're authored decoration, the same boundary erase draws. - The mask is an ordinary field:
readField/writeFieldsee it (documented in the ObjectAPI field docs), the scatter material matcher applies it (ground the renderer shows as pure painted material reads as that material for material-conditioned beds), and the terrain inputs hash carries it name-keyed beside the painted material fields. - Texture transitions sharpened (the blendWidth follow-up #11801 deferred). The pbr weight resolve's crossfade window was floored at
TERRAIN_HEIGHT_BLEND_DEPTH(0.18) — as wide as the ±0.18 texture-height bias that decides which layer wins each texel, so across a whole transition band the losing layer rendered THROUGH the winner: 2:1 and 50/50 paint ratios both read as a translucent ghost-merge. The window floor is now its own constant,TERRAIN_BLEND_SHARP_WIDTH = 0.06— the height bias still carves which texels each material wins (grass blades between stones), but the loser fades out within a texel-scale band: dominant paint reads as dominant-with-speckle, partial rims dither instead of washing. Non-pbr paths (dither, tier ≥ 1 winner-take-all) are untouched. Theverify-terrain-blenddevice gate is re-pinned to the sharpened character. - Pinned in
brush-material-erase-replace.test.ts(mask raised proportionally, erased back down, spec-dropped when emptied, restored by one undo, untouched by additive paint) andterrain-jobs.test.ts(a full-mask texel composes as painted material alone; the mask changes the inputs hash). - Terrain
textureScaleadopts meters-per-tile (the object-material semantics). The heightmap material pack conversion (features/terrain/config/factory.ts) previously computed shader repeats-per-meter as0.4 × textureScale— a repeat-frequency multiplier inverted from object materials' "meters each tile covers", ~1000× denser for the same small value; the knob-name collision burned a creator world authored at 0.05–0.08 (thread 1785625835). NowmetersPerTile = textureScale(clamped ≥ 0.001), shader repeats/m =1 / metersPerTile. No stored-value conversion (jacob's ruling, ts 1785626860). Defaults andtile-only materials pack byte-identical values to pre-flip; savi docs (api-reference §TerrainDef, heightmap-terrain skill) carry the flip note + conversion. - Instanced places work in client-hosted rooms — rail.enterPlace relay authority (#11898); server is the place host on container tier (#11899)
- Render scripts read
ctx.device({class, coarse}, session-frozen);getClientHealth()rows carrydeviceClass(#11923) - Per-lane draw census in render health + primitive-lane drift sentinel + refusals name entities per family (#11921)
- Single-motion avatars play their only clip instead of T-posing; substitution named in diagnostics (#11922)
- KTX2/Basis transcode moved off the render worker; per-mip chunked uploads (#11878)
- Firefox: WebGPU canvas reconfigured after transferToImageBitmap — cures the Gecko session-death class (#11862)
- Tab press-wedge cures: forced tab-up on remount, stale-press cap, no-device voice ladder (#11880)
- The server hosts a place only when no client can (#11934, refines #11899). The permanent server host claimed the fallback seat for every loaded place even with players connected, so the container simulated every place at once (the zoo on staging: 21 physics runtimes on 2 vCPU, tick 30→13/s). Now the client election outranks the server's fallback seat — the server claims seats only in an empty room, releases them as clients arrive (epoch chain intact in both directions), and every server-side sim gate (physics dispatch, interpreter, default-place pause, place cleanup) is per-held-seat, so the server sim goes fully dormant while players are online. Cures the staging tick collapse observed in dumps 90873f77/19e6f4c0. Pinned in
server-place-host.test.ts.