engine v4.2.0
Continuum
May 9, 2026
Saved games, smoother animation, sharper aim, and a basket of polish fixes you'll feel right away.
what's new
Saved games, smoother animation, sharper aim, and a basket of polish fixes you'll feel right away.
Save your games
- Player progress persists across sessions — position, inventory, and stats all come back when players return.
- World state persists too: tag objects to save, set up cron jobs to auto-save on a timer, and graceful shutdowns save everyone before the server goes down.
- Multi-place persistence — walk through a portal, disconnect, and come back in the same place with your stuff.
- Static initial values on an object's
statefield are applied automatically. Add new fields later and returning players get the defaults without losing their saves. - One unified state system — no more confusing split between "persistent" and "ephemeral" state.
Animation, reborn
- One animation system now: named mixer channels driven by
updateChannelfrom your scripts. - Two-clip blending (Walk over Idle, Cast over Run) is first-class — give channels names and they layer cleanly.
Smarter pointer & raycasts
- The "I shot myself" bug is gone —
api.raycastexcludes the caller by default. - Sphere casts now work for aim assist and area effects:
{ shape: { sphere: radius } }. - Click-to-throw, click-to-shoot, and other click actions fire exactly where you're looking. No more first-person balls flying off behind you.
- The first click that locks the cursor no longer wastes your first action.
Bug fixes you'll feel
- Signs Savi makes no longer spin their text to face you and clip through the panel.
- Strands of fairy lights and bunting now actually look like strands — a thin cord drooping through your points with cute bulbs or flags hung from it, no random poles in the scene.
- Stacks of bottles, mugs, and other small lathe-shaped objects don't explode into orbit anymore. They just sit on the table.
Behind the scenes
Existing games migrate to all of these automatically when you upgrade:
- One
api.patch()method replaces eight separate per-slice patches. - One way to make tubes, roads, pipes, and fences:
spline. The redundantpathproperty is gone. - Eight pointer/aim/raycast methods replaced by two clearer ones.
›technical notes
- REMOVED:
DrawAnimationcomponent (draw/animation). Animation state flows throughdraw/mixeronly. Legacymodel: { id, animation }is converted to a_defaultmixer channel inderive-appearance.syncAnimationForModel. - REMOVED:
Animated3DCharacterFeature's client-side DrawModel deriver. The server now writes DrawModel alongsideDrawAnimated3DCharacterso it replicates normally — no per-client derivation, no prediction mismatches. - REMOVED from renderer (
render_v2/state/model.ts):tickLocomotion,computeLocomotionVelocity,AnimConfig,narrowAnimated3DCharacter,record.animConfig, the"loco"LayerOwner, and the locomotion constants. The renderer no longer knowsdraw/animated-3d-characterexists; it consumes mixer channels only. - REMOVED:
"draw/animated-3d-character"fromRENDER_COMPONENTS— the component no longer crosses the render channel. - ADDED:
Animated3DCharacterLocomotionFeature(server+client) — back-compat shim that translatesDrawAnimated3DCharacterconfig + entity velocity into mixer channels and handles facing rotation. The only file in the engine that knows a3dc exists; deleting it removes a3dc support entirely. - ADDED:
3d-animationsskill — covers mixer channel API, locomotion recipe, one-shots, bone masking. - ADDED: Fixed humanoid capsule for character controllers without an explicit collider, replacing the model-derived convex hull that produced unstable collisions on terrain.
- ADDED:
_defaultauto mixer channel for skinned models that have no explicit animation/mixer, so loaded models don't sit in bind pose. - DEPRECATED:
DrawAnimated3DCharacter/animated3DCharacter. Hidden from Savi's prompt. Runtime continues to accept it via the compat shim. - CHANGED:
writeDrawAnimated3DCharacterno longer writesDrawModelas a side effect; the interpreter andsetPropertycallers composewriteDrawAnimated3DCharacter+writeDrawModelexplicitly. - BREAKING: Removed built-in auto-persistence system (
engine.persistenceconfig, player/room/singleplayer save systems) - BREAKING: Removed
player.onDisconnectBehaviorRef — replaced byonPlayerDisconnectedlifecycle hook - BREAKING: Removed
patchEphemeralState(),replaceEphemeralState(),setEphemeralState()from ObjectAPI — ephemeral state unified into TomeState - BREAKING: Removed
onDisconnectfrom compiled behavior hooks - Added
engine.behaviors: BehaviorRef— lifecycle hooks as named exports (same composition pattern as entity behaviors) - Added
engine.crons: { schedule, script }[]— scheduled jobs with config-side scheduling - Added lifecycle hooks:
onPlaceStart,onPlaceShutdown,onPlayerConnected,onPlayerDisconnected - Removed
objectApi.defaultState()— static defaults belong on the spec'sstatefield; usepatchState()inonSpawnfor computed values - Added
objectApi.awaitJob(jobId)— Promise-based job result for async lifecycle/cron hooks - Added
objectApi.getPlaces()— returns all spec + instanced place IDs - Added
LifecycleContextResource— built-in storage jobs execute immediately via SDK in lifecycle context - Added async detection in entity behavior compilation —
async function onSpawn/update/onInput/...rejected at compile time - Added
skipOnSpawnparameter through attachClient chain — entity creation split from onSpawn for persistence - Added
connection.rejectedcontrol message with client-side error overlay - Added graceful shutdown: worker-thread dispose awaits completion, disconnect hooks run for all connected players
- Added
specAppliedgate — connections queue until first spec is applied - Lifecycle scripts compiled once per ref (module-level state shared across hooks)
objectApi/cameraApiparameter naming standardized across all skills- Added
persistTerrainEdits?: booleantoPlaceCreateOptions— opt-in voxel terrain edit storage - Stripped non-voxel persistence from place-persistence system (object spawn/destroy/state tracking removed — use lifecycle hooks instead)
- BREAKING: Removed eight per-slice patch methods (
patchAtmosphere,patchTerrain,patchPlayer,patchCamera,patchInputs,patchGodMode,patchUi,patchEngine) from ObjectAPI. - Added
api.patch(path: string, value: Record<string, unknown>)— single method that dispatches to the right spec slice based on dot-path. Existing per-slice validation preserved internally. - Per-place targeting now uses path syntax:
api.patch("places.<id>.atmosphere", v)andapi.patch("places.<id>.terrain", v)replace the old second-arg form. patchState,patchEphemeralState, andpatchObjectStateare unchanged — they target entity state, a different concept.- Internal recorded mutation
kindtags (patchAtmosphere,patchTerrain, etc.) are unchanged so persistence/replay/serialization stay stable. - BREAKING (public API):
pathremoved fromObjectPropertiestype. Runtime handler retained for backward compatibility — existing games' specs still parse and render. - The string-keyed
setProperty("path", ...)overload still acceptspathat runtime via the legacy registry entry. The typedsetProperty<K extends WritableProperty>overload no longer admits"path"(use"spline"). PathSpecis no longer@tomeapi-tagged and no longer appears in the generated Tome API prompt.kind: "pipe"inSplineSpecmaps to the same tube primitive used by the legacypathhandler, so visual output is identical for the common point-array tube case.- BREAKING: Removed from ObjectAPI:
raycastPhysics,raycastPhysicsAll,raycastPhysicsDown,getAimDirection,getPointerDirection,getPointerRay,getAimOrigin,directionFromYawPitch,rotationFromDirection. - Added:
api.raycast(origin, direction, distance | opts)with positional and options overloads. DefaultignoreSelf: true. Supportsshape: { sphere: number }andmultiple: true. - Added:
api.getInputRay(input?)returning{ origin, direction } | null. Reads from pointer axes (handles both pointer-locked center and cursor modes via existing input-axis dispatch). directionFromYawPitchandrotationFromDirectionavailable viarequire('builtin/vec3').getAimDirection(input?)andgetPointerDirection(input?)now accept omitting the input argument — falls back to the camera state resolved bygetCamera()so they work inupdate/onCollide/etc., not justonInput. Eliminates the crypticundefined.axescrash when scripts called these from non-input hooks.- Pointer-direction ray now uses
rendererTransform.pos/.rot(renderer-authoritative camera SAB) instead of the script-side ECSRotation. Under pointer-lock the script-side rotation drifts from on-screen orientation because it integrateslookX/lookYwithout the renderer'sMOUSE_SCALE, so click-to-throw / hit-detection rays were diverging from the crosshair. Fixed inresolveViewState. - Suppressed the mousedown that acquires pointer lock from also firing as a left-button press. Previously the first click both locked the cursor and triggered whatever action was bound to mouse-left (throw, shoot, place block).
- Collider override sizing:
physics.collider: "box" | "sphere" | "capsule"overrides now derive dimensions from the primitive's actual bounds instead of falling back to the engine's hardcoded 1m defaults. Extended to bespokeMesh primitives (lathe, cone, pyramid, hemisphere, ellipsoid, torus, etc.) viagetBespokeGeometryBySignature. Fixes invisible 1m capsules under tiny lathe milk-bottles that overlapped and exploded in stacks. - Sign text: when an object has explicit
rotation(oryaw),text.billboarddefaults to"none"so labels stay flush to the surface they were placed on instead of billboarding through the panel. - Stringlight default layout is now a poleless cord+primitive-bulbs strand that follows the authored points exactly. Set
poleHeightto opt back into the freestanding pole+wire layout. Bulbs are emissive sphere primitives (cheap), not point lights. - Bannerline default layout is now a poleless cord+pennants strand that follows the authored points exactly. Set
poleHeightto opt back into the freestanding pole+wire layout. - Heightmap
terrain.materialsnow coerces aRecord<id, material>shape to the typed array form instead of crashing the runtime when malformed specs arrive (Savi was occasionally confusing heightmap with voxel material shapes). - Spline
points/beziersdoc: added the frame-rule explanation (offsets from the owner; standalone splines anchor at scene root). Same content threaded into the prompt's spline section. - New audit test
primitive-collider-matrix.test.tsexercises every primitive kind × every collider override (81 cases) and asserts the result is sized — catches future primitives that ship without aderiveColliderFromBoundsentry.
›migration notes
draw/mixer (Record<string, MixerChannel>) is now the engine's only animation surface. The old draw/animation component is gone. Existing games keep working — the legacy spec forms translate silently.
model: { id, animation: { ... } }→ silently converted to a_defaultmixer channel at spec apply time. No spec changes required.animated3DCharacter: { ... }(and the booleantrue/falseforms) → silently driven throughDrawMixerby theAnimated3DCharacterLocomotionFeatureserver-side compat shim. No spec changes required. Slated for full removal in a future major.
New authoring shape
Use model: 'cdn/x.glb?animations=Idle,Walk,Run' and a behavior script that writes mixer channels via updateChannel(name, opts) / clears them with updateChannel(name, null). List every clip the entity will play in the ?animations= URL — clips not listed are stripped during baking and silently T-pose. See the new 3d-animations skill for the locomotion recipe, one-shots, and bone masking.
Behavior changes
- Weights are contribution ratios, not normalized fractions. Floor at
0.001to keep layers alive during crossfades. mask: { from: 'Spine' }masks a channel to a bone subtree for upper/lower body splits.- One-shot channels with
duration:auto-clear once elapsed exceeds duration. ?animations=in the model URL is load-bearing. Missing clips cause T-pose.
This change will break persistence in all games relying on the default persistence system. Persistence will no longer work unless you re-implement it using the new lifecycle methods. Use the lifecycle-and-persistence skill to better understand how to migrate.
You must remove the engine.persistence key from the spec.
A spec that does not have engine.persistence set was still subject to the engine's old default persistence. They will lose this feature if you do not add persistence back for them.
If you have scripts using the player.onDisconnect behavior hook, remove it from the player template and move that logic into an onPlayerDisconnected(objectApi) export in your engine.behaviors script instead.
Any scripts that were using setEphemeralState, replaceEphemeralState, or patchEphemeralState should use replaceState or patchState appropriately. Keep in mind that a blind replacement of replaceEphemeralState to replaceState could have unintended consequences.
If you have voxel worlds inside of Places and need them to persist, persistence: "persistent" no longer saves voxel terrain edits, you must add persistTerrainEdits: true to the place config.
If you are declaring default state in onSpawn for objects that does not need to be calculated at runtime, you need to declare this inside of player.state (for the player) or places.*.objects[].state (for objects).
Consider writing a migration audit script along these lines:
for each script in spec.scripts:
if source mentions "replaceEphemeralState" or "setEphemeralState":
flag → migrate to replaceState/patchState
if source mentions "patchEphemeralState":
flag → migrate to patchState
if onSpawn calls patchState or replaceState with static values:
flag → consider moving defaults to the spec's state field instead
if spec.engine.persistence exists:
flag → remove engine.persistence from spec
if player template has onDisconnect behavior:
flag → move to onPlayerDisconnected in engine.behaviors
for each place in spec.places:
if terrain kind is "voxel" and place had persistence: "persistent":
flag → add persistTerrainEdits: true to createIfMissing
engine.persistence removed
Remove engine.persistence from the spec entirely. The built-in auto-persistence system no longer exists. Use lifecycle hooks instead (see below).
player.onDisconnect removed
Remove player.onDisconnect from the player template. Use onPlayerDisconnected in engine.behaviors instead.
patchEphemeralState / replaceEphemeralState removed
Replace all objectApi.patchEphemeralState(...) with objectApi.patchState(...). Replace all objectApi.replaceEphemeralState(...) with objectApi.replaceState(...). There is no more ephemeral state — all state is unified into one layer.
Put static initial state on the spec, not in onSpawn
Move static initial values (health, gold, speed, etc.) to the object's state field in the spec. The engine applies spec state before onSpawn runs, and the hot-update merge preserves runtime changes. Use patchState() in onSpawn only for computed values derived from runtime (position, tick, spawned child IDs).
New persistence via lifecycle hooks
Add engine.behaviors to the spec referencing a script that exports lifecycle hooks:
{
"engine": {
"behaviors": ["scripts/lifecycle.js"]
}
}
The script exports named async hooks: onPlaceStart(objectApi, placeId), onPlaceShutdown(objectApi, placeId), onPlayerConnected(objectApi), onPlayerDisconnected(objectApi). All receive ObjectAPI as the first parameter. Use objectApi.awaitJob(jobId) for async storage operations.
New cron jobs
Add engine.crons for scheduled tasks:
{
"engine": {
"crons": [{ "schedule": "*/5 * * * *", "script": "scripts/cron/save.js" }]
}
}
Scripts export cron(objectApi).
New persistTerrainEdits option on places
To persist voxel terrain edits (block breaks/places) across server restarts, set persistTerrainEdits: true in createIfMissing. The persistence field on places still controls place lifetime (ephemeral/session/persistent) and is unchanged.
api.enterPlace({
placeId: "dungeon",
createIfMissing: { instanceMode: "instanced", persistence: "session", persistTerrainEdits: true },
});
Eight separate patchX methods on ObjectAPI consolidated into one patch(path, value) primitive that takes a dot-path into the spec. Update all script call sites.
| Before | After |
|---|---|
api.patchAtmosphere(p) | api.patch("atmosphere", p) |
api.patchTerrain(p) | api.patch("terrain", p) |
api.patchTerrain(p, "main") | api.patch("places.main.terrain", p) |
api.patchPlayer(p) | api.patch("player", p) |
api.patchCamera(p) | api.patch("camera", p) |
api.patchInputs(p) | api.patch("inputs", p) |
api.patchGodMode(p) | api.patch("godMode", p) |
api.patchUi(p) | api.patch("ui", p) |
api.patchUi(p, "creatorUi") | api.patch("creatorUi", p) |
api.patchEngine(p) | api.patch("engine", p) |
Behavior is identical per slice; only the dispatch surface changed.
The path object property is removed from the public API in favor of spline. SplineSpec already covers tube-along-points rendering plus many richer kinds (road/pipe/fence/etc.). Existing games keep working — the runtime still accepts path — but Savi should migrate scripts going forward.
| Before | After |
|---|---|
properties: { path: { points: [...], radius: 0.1 } } | properties: { spline: { kind: "pipe", points: [...], radius: 0.1 } } |
properties: { path: { beziers: [...], radius: 0.2 } } | properties: { spline: { kind: "pipe", beziers: [...], radius: 0.2 } } |
setProperty("path", { points, radius }) | setProperty("spline", { kind: "pipe", points, radius }) |
Same fields (points, beziers, radius, closed, samples) work as-is on the spline. Just add kind: "pipe" to declare which spline variant. (For roads, use kind: "road"; for fences/walls/handrails/etc., see the SplineSpec kind list.)
The aim/pointer/raycast surface (9 methods) consolidated to 2 primitives. Replace every script call site as follows.
Pointer/aim helpers → getInputRay
| Before | After |
|---|---|
api.getAimDirection(input) | api.getInputRay(input)?.direction |
api.getPointerDirection(input) | api.getInputRay(input)?.direction |
api.getPointerRay(input) | api.getInputRay(input) |
api.getAimOrigin() | api.getInputRay(input)?.origin (camera position) — for "feet + eye height" fallback compose { x: feet.x, y: feet.y + 1.6, z: feet.z } |
api.getAimOrigin(h) | const feet = api.getProperty('feetPosition'); const origin = { x: feet.x, y: feet.y + h, z: feet.z }; |
Math helpers → builtin/vec3
// before
const dir = api.directionFromYawPitch(yaw, pitch);
const rot = api.rotationFromDirection(dir);
const rot = api.rotationFromDirection(dir, "y");
// after
const { directionFromYawPitch, rotationFromDirection } = require("builtin/vec3");
const dir = directionFromYawPitch(yaw, pitch);
const rot = rotationFromDirection(dir);
const rot = rotationFromDirection(dir, "y");
Raycasts → api.raycast
| Before | After | Notes |
|---|---|---|
api.raycastPhysics(origin, dir) | api.raycast(origin, dir) | auto-excludes self (NEW default) |
api.raycastPhysics(origin, dir, { maxDistance: 100 }) | api.raycast(origin, dir, 100) | distance positional |
api.raycastPhysics(origin, dir, { maxDistance: 100, ignoreIds: [api.id] }) | api.raycast(origin, dir, 100) | self-exclusion is now default; remove [api.id] |
api.raycastPhysics(origin, dir, { ignoreIds: ['rock-1', api.id] }) | api.raycast(origin, dir, { ignoreEntities: ['rock-1'] }) | other ignores stay; self stays implicit |
api.raycastPhysics(origin, dir, { includeTags: ['enemy'] }) | api.raycast(origin, dir, { includeTags: ['enemy'] }) | rename only |
api.raycastPhysics(origin, dir, { excludeTags: ['friendly'] }) | api.raycast(origin, dir, { excludeTags: ['friendly'] }) | rename only |
api.raycastPhysicsAll(origin, dir, opts) | api.raycast(origin, dir, { ...opts, multiple: true }) | always returns array sorted by distance |
api.raycastPhysicsDown() | api.raycast(api.getProperty('feetPosition'), { x: 0, y: -1, z: 0 }) | auto-excludes self |
api.raycastPhysicsDown(2.0) | api.raycast(api.getProperty('feetPosition'), { x: 0, y: -1, z: 0 }, 2.0) | distance positional |
api.raycastPhysicsDown({ maxDistance: 2.0, includeTags: ['ground'] }) | api.raycast(feet, { x: 0, y: -1, z: 0 }, { distance: 2.0, includeTags: ['ground'] }) |
Behavior changes worth noticing
raycastauto-excludes the caller by default. OldraycastPhysicsdid NOT — callers had to addignoreIds: [api.id]explicitly. To opt out (rare, e.g. self-targeting effects):api.raycast(origin, dir, { ignoreSelf: false }).multiple: truereturns hits sorted by distance (always, ascending). OldraycastPhysicsAllreturned in physics-engine order.raycastaccepts shapes:{ shape: { sphere: radius } }for sphere-casts. Previously not exposed.