engine v4.4.0
Solid
May 15, 2026
Things stack right, worlds load clean, and clicks land where you point.
what's new
- Stacking just works. A mug on a chair on a house keeps its size. Ask Savi to put something on top of something else and she'll land it first try — huge for any game where things sit on shelves, stack into towers, or get carried around.
- Worlds load clean. Things appear at the right size right away. No more pop-in where stuff suddenly grows or shrinks as the level comes in.
- Clicks land where you point. The sky doesn't steal clicks anymore, and big crowds of repeating things (trees, rocks, props) can be clicked one by one. Shooters, click-to-place, and pick-up-anything games all feel sharper.
- Effects stick to surfaces the right way. Bullet holes, splats, dust puffs and decals face the wall they hit instead of floating sideways.
- Camera stays still when you open the menu — no more drift while you're trying to read.
- Heads up: a few old scenes might look a little different where you'd attached one thing to another. Take a peek and tweak if anything looks off.
›technical notes
Scene graph + layout (breaking)
- BREAKING: scene graph rewrite (#6480). Replaces
Scale+LayoutScale+RenderScale+LayoutScaleFeature+RenderScaleFeature+ the multi-stage hierarchy composition with three explicit components, each with one job:LocalScale/LocalRotation/LocalFeetPosition/LocalPivot— authored inputs.WorldScale/WorldRotation/WorldFeetPosition— solved hierarchy outputs (World* = parent.World* ⊗ Local*). Pure tree math, no asset awareness.GeometryScale— per-asset fit factor derived from(DrawModel.rawBounds, TomeLayout). Computed for every entity, leaf-only at consumption, never inherited.
- BREAKING: fixed layout cascade bug — a root's
layout.maxExtentsno longer shrinks every descendant. Renderer / physics now readWorldScale × GeometryScaleper entity instead of a composedRenderScalethat mixed asset-fit into the hierarchy. Regression test:tome/__tests__/mug-on-chair-on-house.test.ts. - BREAKING: fixed parented-clamp drop bug — a parented entity's own
layout.maxExtentsnow applies.GeometryScaleis written regardless of parenthood. Regression test:tome/__tests__/parented-layout-clamp.test.ts. - BREAKING:
maxExtentsis a ceiling (factor = min(1, maxExtents / rawBounds)),minExtentsis a floor (factor = max(1, minExtents / rawBounds)),minExtents === maxExtents= exact fit. - Added
api.getWorldBoundsBox(id): { min, max, size, center } | null. Backed bytome/api/world-bounds.ts;WorldBoundsBoxtype surfaces in Savi's prompt via the shared-schemas section. - Renderer reads
WorldScale × GeometryScaleuniformly. Model matrix =T(WorldFeetPosition) × R(WorldRotation) × S(WorldScale × GeometryScale). Feet alignment uses the combined factor. - Rapier reads
WorldScale × GeometryScaleso collider geometry matches visible geometry on every entity. Pose stays in pure graph-transform space; feet-to-body-center offset =(rawHeight/2) × WorldScale × GeometryScale.
Asset bounds prefetch
- New server-side
BoundsPrefetchFeature(#6490) discovers entities withDrawModel, submits Range-fetch jobs that parse GLB headers (~64KB per model) off the main thread, and writes results toGameSpecResource.assets.metadata. SupportsKHR_mesh_quantizationdequantization. - Bounds persist to Supabase via
patchAssets, so subsequent room starts find them cached and avoid the refetch. - Hooks-driven, not per-tick (#6491):
onComponentAdd/onComponentSet(DrawModel)+ a one-time seed of existing entities. Steady-state cost is zero. CatchesapplySpec,api.spawnObject, and runtimesetProperty("model", …)swaps without scanning script source. - Removed the now-unused
collectModelUrls/MODEL_URL_INLINE_REspec walker.
Raycast
- Raycast results now include per-hit surface normals (packed + decoded) for both CPU and GPU paths (#6504).
- CPU/GPU raycast behavior aligned for first-hit queries; both use snapped ray directions so results are consistent across paths.
- Sky meshes are excluded from raycasts — click queries no longer hit the skybox before the world.
- Raycast reset and distance handling hardened against invalid (NaN/Infinity) values.
- Fixes raycast against
IndirectBatchedMeshin the compute raycast path.
Input
handleMouseMoveinengine/input/raw-capture.tsnow gates ongetInputMode() === "overlay"like the other input handlers (#6501). Pointer-lock mouse deltas no longer rotate the camera under an open overlay.
Skills (Savi-facing)
- Removed six phantom API method names from the engine skill files (#6500). All were taught to Savi but didn't exist on
ObjectAPI/TomeCameraAPI, causing tool-call failures or silent[Tome] patchTerrain() ignored…rejections:world-composition.md:api.setSpec("...decorations")→api.patch("terrain", { decorations: … })voxel-terrain.md:api.patchTerrain({…})→api.patch("terrain", { addMaterials, addMarks, … })voxel-terrain.md:objectApi.getPointerRay(input)→objectApi.getInputRay(input)pointer-raycasting.md:getPointerRay/getAimOrigin→getInputRay/getProperty("feetPosition")+ offsetpointer-raycasting.md:objectApi.raycastPhysics(…)→objectApi.raycast(…)3d-billboard-sprites.md:objectApi.getPosition()→objectApi.getProperty("feetPosition")turrets.md:objectApi.damage(id, n)→getObjectState+patchObjectState({ health: … - n })
›migration notes
1. maxExtents is now a ceiling, not a target fit
Previously maxExtents scaled both up and down to fit the box. After 4.4 it only shrinks oversized models — undersized models stay at their authored size. To force a specific size, set minExtents equal to maxExtents.
For every object with layout.maxExtents but no layout.minExtents:
for each obj in spec.places.*.objects with obj.properties.layout?.maxExtents and !obj.properties.layout?.minExtents:
via run_script:
api.setObjectProperty(obj.id, "layout", {
minExtents: obj.properties.layout.maxExtents,
maxExtents: obj.properties.layout.maxExtents,
});
Scatter templates follow the same pattern — carry minExtents forward from each template's maxExtents.
2. Scene graph rewrite fixes two old transform bugs
The spec format is unchanged, but the rendered result of an unchanged spec can differ in two specific shapes:
- Layout cascade: a root's
layout.maxExtentsno longer shrinks its descendants. If you'd visually compensated for that bug, descendants now look bigger. - Parented clamp drop: a parented entity's own
layout.maxExtentsnow applies. If you'd added a clamp that was silently ignored, the entity now shrinks.
Per game, find and fix:
for each place in spec.places.*:
// Case 1: clamped root with any descendants
for root in entities where layout.maxExtents and !parent:
factor = min(maxExtents.{x,y,z}) / max(rawBounds.{x,y,z}) // the old cascade factor
for descendant in subtreeOf(root):
// either scale descendant.scale *= factor to keep the old look,
// or do nothing (recommended) and accept the fix.
// Case 2: parented entity with its own layout
for entity in entities where layout.maxExtents and parent:
// either remove layout.maxExtents to preserve the old look,
// or accept the (intended) clamp.
A wisp can identify affected entities per game; the creator decides per scene whether to compensate or accept the fix.
3. New API: api.getWorldBoundsBox(id)
Returns { min, max, size, center } | null for the world-space AABB. Use it instead of multiplying getProperty("scale") by rawBounds when stacking or aligning objects. Returns null until raw bounds are loaded (resolves next tick).