Spawn

Make Games with Words

Explore or make your own

spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v4.5Surface Tension2 daysengine v4.4Solid1 weekengine v4.3Groovy1 weekengine v4.2Continuum2 weeksengine v4.1Foundations3 weeksengine v0.1Genesis3 weeks
← All posts

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 state field 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 updateChannel from 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.raycast excludes 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 redundant path property is gone.
  • Eight pointer/aim/raycast methods replaced by two clearer ones.
›technical notes
  • REMOVED: DrawAnimation component (draw/animation). Animation state flows through draw/mixer only. Legacy model: { id, animation } is converted to a _default mixer channel in derive-appearance.syncAnimationForModel.
  • REMOVED: Animated3DCharacterFeature's client-side DrawModel deriver. The server now writes DrawModel alongside DrawAnimated3DCharacter so 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 knows draw/animated-3d-character exists; it consumes mixer channels only.
  • REMOVED: "draw/animated-3d-character" from RENDER_COMPONENTS — the component no longer crosses the render channel.
  • ADDED: Animated3DCharacterLocomotionFeature (server+client) — back-compat shim that translates DrawAnimated3DCharacter config + 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-animations skill — 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: _default auto 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: writeDrawAnimated3DCharacter no longer writes DrawModel as a side effect; the interpreter and setProperty callers compose writeDrawAnimated3DCharacter + writeDrawModel explicitly.
  • BREAKING: Removed built-in auto-persistence system (engine.persistence config, player/room/singleplayer save systems)
  • BREAKING: Removed player.onDisconnect BehaviorRef — replaced by onPlayerDisconnected lifecycle hook
  • BREAKING: Removed patchEphemeralState(), replaceEphemeralState(), setEphemeralState() from ObjectAPI — ephemeral state unified into TomeState
  • BREAKING: Removed onDisconnect from 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's state field; use patchState() in onSpawn for 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 skipOnSpawn parameter through attachClient chain — entity creation split from onSpawn for persistence
  • Added connection.rejected control message with client-side error overlay
  • Added graceful shutdown: worker-thread dispose awaits completion, disconnect hooks run for all connected players
  • Added specApplied gate — connections queue until first spec is applied
  • Lifecycle scripts compiled once per ref (module-level state shared across hooks)
  • objectApi/cameraApi parameter naming standardized across all skills
  • Added persistTerrainEdits?: boolean to PlaceCreateOptions — 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) and api.patch("places.<id>.terrain", v) replace the old second-arg form.
  • patchState, patchEphemeralState, and patchObjectState are unchanged — they target entity state, a different concept.
  • Internal recorded mutation kind tags (patchAtmosphere, patchTerrain, etc.) are unchanged so persistence/replay/serialization stay stable.
  • BREAKING (public API): path removed from ObjectProperties type. Runtime handler retained for backward compatibility — existing games' specs still parse and render.
  • The string-keyed setProperty("path", ...) overload still accepts path at runtime via the legacy registry entry. The typed setProperty<K extends WritableProperty> overload no longer admits "path" (use "spline").
  • PathSpec is no longer @tomeapi-tagged and no longer appears in the generated Tome API prompt.
  • kind: "pipe" in SplineSpec maps to the same tube primitive used by the legacy path handler, 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. Default ignoreSelf: true. Supports shape: { sphere: number } and multiple: 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).
  • directionFromYawPitch and rotationFromDirection available via require('builtin/vec3').
  • getAimDirection(input?) and getPointerDirection(input?) now accept omitting the input argument — falls back to the camera state resolved by getCamera() so they work in update/onCollide/etc., not just onInput. Eliminates the cryptic undefined.axes crash 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 ECS Rotation. Under pointer-lock the script-side rotation drifts from on-screen orientation because it integrates lookX/lookY without the renderer's MOUSE_SCALE, so click-to-throw / hit-detection rays were diverging from the crosshair. Fixed in resolveViewState.
  • 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.) via getBespokeGeometryBySignature. Fixes invisible 1m capsules under tiny lathe milk-bottles that overlapped and exploded in stacks.
  • Sign text: when an object has explicit rotation (or yaw), text.billboard defaults 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 poleHeight to 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 poleHeight to opt back into the freestanding pole+wire layout.
  • Heightmap terrain.materials now coerces a Record<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/beziers doc: 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.ts exercises every primitive kind × every collider override (81 cases) and asserts the result is sized — catches future primitives that ship without a deriveColliderFromBounds entry.
›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 _default mixer channel at spec apply time. No spec changes required.
  • animated3DCharacter: { ... } (and the boolean true/false forms) → silently driven through DrawMixer by the Animated3DCharacterLocomotionFeature server-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

  1. Weights are contribution ratios, not normalized fractions. Floor at 0.001 to keep layers alive during crossfades.
  2. mask: { from: 'Spine' } masks a channel to a bone subtree for upper/lower body splits.
  3. One-shot channels with duration: auto-clear once elapsed exceeds duration.
  4. ?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.

BeforeAfter
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.

BeforeAfter
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

BeforeAfter
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

BeforeAfterNotes
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

  1. raycast auto-excludes the caller by default. Old raycastPhysics did NOT — callers had to add ignoreIds: [api.id] explicitly. To opt out (rare, e.g. self-targeting effects): api.raycast(origin, dir, { ignoreSelf: false }).
  2. multiple: true returns hits sorted by distance (always, ascending). Old raycastPhysicsAll returned in physics-engine order.
  3. raycast accepts shapes: { shape: { sphere: radius } } for sphere-casts. Previously not exposed.

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

Surface Tensionengine v4.52 daysSolidengine v4.41 weekGroovyengine v4.31 weekContinuumengine v4.22 weeksFoundationsengine v4.13 weeksGenesisengine v0.13 weeks
← All posts