Spawn
spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.0For Real1 weekengine v4.6Atelier1 weekengine v4.5Surface Tension2 weeksengine v4.4Solid3 weeksengine v4.3GroovyMay 13, 2026engine v4.2ContinuumMay 9, 2026engine v4.1FoundationsMay 4, 2026engine v0.1GenesisApril 29, 2026

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

For Realengine v5.01 weekAtelierengine v4.61 weekSurface Tensionengine v4.52 weeksSolidengine v4.43 weeksGroovyengine v4.3May 13, 2026Continuumengine v4.2May 9, 2026Foundationsengine v4.1May 4, 2026Genesisengine v0.1April 29, 2026
← All posts
← All posts

engine v5.0.12

Engine v5.0.12

June 12, 2026

A patch in the For Real line.

what's new

  • Sounds in 2D games are actually audible now! Dig thunks, impacts, and pickups were playing at a tiny fraction of their volume because the game listened from the camera instead of your character.
  • Scripts can now know when a sound finishes and how long clips really are — timed dialogue and voice-over sequencing stop guessing with timers (no more overlapping lines, dead air, or drift).
  • Looping sounds and music now wrap smoothly — no more click, gap, or hiss at the seam every time a generated loop repeats.
  • Sounds no longer burst all at once after a slow load — anything timed that "played" while the loading screen was still up is skipped instead of piling up, and your music/ambient loops start cleanly the moment the world appears.
  • Fixed a rare server bug where a player could fall through one spot of the terrain forever — the engine now detects when its self-repair isn't working and rebuilds the whole area's ground instead of retrying the same broken fix all night.
  • Game servers now recover from rare internal freezes in about a minute on their own — previously a frozen world could stay stuck (changes not applying, joins hanging) until we shipped an update.
  • Small characters stand still now! Tiny avatars and creatures (gnome-sized and down) had vibrating, jittery legs from the automatic foot grounding — their feet now plant just as solidly as full-size characters.
  • Statues and frozen poses no longer twitch or slowly twist their feet — characters without playing animations hold exactly the pose you gave them, and turning foot grounding off returns them to their authored pose.
  • Big sprites, animations, and textures that are generated mid-game show up way faster and more reliably — one generation pass instead of repeated stalled attempts.
  • The cursor does what your game says, immediately: pointer-lock changes from Savi or scripts apply live, hideCursor()/showCursor() work in every camera mode, closing a dialog still snaps you straight back into mouse-look, and converting a 3D world to 2D frees the cursor on its own.
  • Multiplayer smoothness: fixed a bug where tumbling or settled physics objects could trigger constant invisible corrections, quietly burning performance. Ragdolls and debris now cost what they should.
  • Multiplayer resilience: a client that fell badly out of sync could spiral — correcting nonstop until the game ran out of memory. Corrections now pace themselves under load (a brief moment of catch-up instead of a freeze), and the common case got cheaper across the board.
  • Savi and creator scripts now get an immediate, precise error when a particles/material/physics value has the wrong shape (e.g. sizeOverLife: [1, 0.4] instead of [[0, 1], [1, 0.4]]) — at the moment of the write, naming the exact field, instead of the value silently landing and breaking rendering later.
  • Particle emitters support shape: { kind: "disc", radius, jitter?, lift? } — particles spawn across a horizontal disc (ground fog, ritual circles, splash rings) instead of a single point.
  • Savi can now draw animated sprites in code, frame by frame — walk cycles, attack swings, and idle bobs bake instantly into a sprite sheet that plays through the normal animation controls. No waiting on image generation to see your characters move.
  • Tilemap worlds no longer need a size! Skip width/height and your tile generator paints forever in every direction — walk a thousand tiles out to sea and the ocean keeps coming, just like infinite terrain in 3D. Existing maps with a set size work exactly like before.
  • Vignettes can now fade themselves out — ask Savi for a damage flash that pulses the screen edges red and melts away on its own, no cleanup timers needed.
›technical notes
  • 2D place modes (2d-side, 2d-top) now mount the audio listener on the controlled character instead of the camera. The side-scroller camera floats 15 m off the play plane, so the camera-mounted listener heard every positional sound from >=15 m — inverse rolloff (refDistance 1) cut all playSoundAt one-shots to ~1/15 gain before authored volume applied, making 2D sfx effectively inaudible. The listener uses a fixed mode-derived basis (side: forward -Z / up +Y; top: forward -Y / up -Z) so sprite facing flips can't invert stereo. 3D places keep the camera-mounted listener; explicit AudioListener entities still take precedence.
  • playSound closes its open loop: behaviors can export an onSoundEnd(sound, api) hook that fires exactly once when a non-looping sound the object played finishes, with sound.durationSeconds carrying the clip's real decoded length (the playback window is duration ÷ pitch). The authority can't decode audio, so clients report each clip's duration once at decode over the existing generic cmd.* channel (engine.soundDuration, the clientHealth precedent — validated, first-report-wins, size-capped, rate-limited); a server system fires the hook from a clip→seconds mirror on the authority's clock. playSound now returns a sound id for tracked one-shots too (loops keep their stopSound id). Tracking is gated on the emitting object's behavior declaring the hook — every other playSound stays exactly as cheap, and the dispatch system is a one-branch no-op when nothing is tracked. Loops never fire onSoundEnd (stopSound is their end). Deliberately no playSequence/queue verb: sequencing composes in creator code.
  • Looping buffer clips are now conditioned once at their first looping start (cached per decoded buffer): near-silent codec edge padding is trimmed (≈ -60 dBFS threshold, capped at 60 ms per side) and an equal-power tail→head crossfade (50 ms) is baked into a copy of the buffer, with the source looping the region past the head fade window via native loopStart/loopEnd. Generated audio rides an MP3 re-encode that structurally adds silent edge padding (~576-sample encoder delay plus end padding, no gapless header), so the previous raw source.loop = true wrap played a silent gap with a click/hiss at every seam. Zero per-frame cost — conditioning is one-time CPU per clip off the hot path; one-shots and the streaming-element path are untouched, and clips shorter than two fade windows skip conditioning and play raw exactly as before.
  • One-shot (non-loop) audio starts that arrive at the client audio renderer before the loading curtain lifts are now dropped instead of queueing. Behavior timers and scripts run during world load, so on slow clients every pre-ready timed sound used to stack — pending on clip loads or scheduled into the not-yet-running AudioContext — and the whole pile fired at once when the curtain lifted. The renderer now takes a sceneHiddenAtStart option (set by the worker browser host) and a one-way markSceneVisible() latch wired to the same gate that hides the loading screen (maybeShowScene); dropped one-shots release their owning entity immediately so the one-shot voice-slot invariant (ledger #214) holds. Looping/ambient and vibe starts are exempt — they begin sounding at curtain-lift as before, nothing stacks. Hosts without a loading curtain are unchanged.
  • Chunk-rescue remediation is now honest about whether it works (ledger #597: a prod player fell through one chunk for 1+ hour while the remediation re-requested the same collider rebuild every 10s and not one landed). After 3 rebuild requests that each had a full cooldown window to land with the rescue still firing, it escalates once — a structured chunk_rescue_remediation_failed log (console + runtime log, visible to Savi's getLogs), a single full-place collider rebuild as the fallback — then drops to a silent self-heal retry every ~10 min instead of logging forever. The repeated-rescue warn also rate-limits to ~once per minute once a loop is steady state (was a fixed 10s cadence for hours).
  • Failed server chunk builds are no longer silent or hot-looped: every job failure outcome (error/canceled/stale/stuck) previously re-marked the chunk for an immediate next-tick resubmit with the reason discarded — a deterministic or starved failure cycled submit→fail→resubmit ~30×/s forever with zero diagnostics. Failures now log the actual reason (first failure immediately, then once a minute) and retry with doubling backoff (1s → 60s cap); a completed build resets both. The chunk-rescue contains players during the backoff exactly as before.
  • A world holding terrain chunk entities but no terrain job resource — a state in which no collider can ever build and every rebuild request dies silently — now raises one loud [terrain/server-request] alarm per world.
  • Container supervision restored to fail-fast (dump d2222b52: a wedged sim worker left a healthy-looking shell serving 504s for 4.5+ minutes while the DO kept routing to it — recovery needed a deploy). The container process now exits on every unexpected thread death, so cf-edge's dead-instance probe (ledger #552) can break the routing pin and cold-boot a fresh process: runtime worker close (thread exit without an error event) is fatal; network worker error/messageerror/close are fatal (previously log-only — a dead network worker was a permanent silent gameplay outage); an egress-pump failure in the network worker escalates to a fatal message instead of logging and leaving every socket open receiving nothing; a failed worker respawn exits instead of stranding an unsupervised shell; a heartbeat ping whose postMessage throws on a live handle counts as death. Default WORKER_HEARTBEAT_TIMEOUT_MS lowered 300s → 60s (4.6× the worst observed legitimate event-loop block), turning the 5-minute wedge-zombie window into ≤60s. Intentional teardown (dispose/manual restart) is token-guarded so it never false-trips the new handlers.
  • Classified two function-valued resources the exec worker's snapshot rebuild was logging error-grade warnings for on every snapshot ("clone disposition but failed the plain-data gate (function value)"): tome/quality-landing-reset-broadcaster (prod, 18×/90min — the server room-runtime's closure over ready connections that pushes quality.landing.reset controls) and prediction/resimulation-stats (staging, 5.0.11 gate — the ResimulationStats class instance the client prediction loop owns). Both are now live-channel in resource-dispositions.ts: the exec worker can never signal clients or run the prediction loop, and both read sites already probe-and-degrade (requestQualityLandingReset falls through, telemetry reads use ?.snapshot()). Behavior is unchanged — the gate was already shipping them as absent; this makes absent-by-design explicit and silences the warning.
  • Audited every other unclassified createResource token in src/ for the same latent class (function/closure/class-instance/handle-bearing values defaulting to clone): none found — everything else is plain data by declared type. The dispositions test's grep-driven audit list gains both names.
  • Fleet resim/mispredict telemetry → Datadog: a new tome/resim-telemetry server system (scheduler everyN cadence, ~60s at the 30Hz default sim rate) folds the existing client-health mirror — the validated per-client prediction windows that already cross on the engine.clientHealth command every ~15s — into one room-level resim_telemetry structured log line (clients, mismatch%, drift/push/skew split, mean+worst corrections/s and resim ms/s, merged top-5 offender components, quaternion double-cover phantomSuspect heuristic). Quiet rooms emit nothing. When a room runs hot (worst client resim >50ms/s or mismatch >40% on a real sample) for 2+ consecutive windows, ONE resim_recording event ships the per-client window detail (top components with classes and srv/cli examples, worst 8 clients), rate-limited to one per room per 10 minutes. The room runtime installs the emitter (spec-push-notifier pattern) over the existing winston→DD intake — appId/roomId/engineVersion/engineHash ride every line via the logger's global context. Zero wire-format changes, zero new round-trips, zero added client cost; the off-cadence per-tick cost is the scheduler's skip.
  • Foot-grounding IK now works at small rig scales (ledger 598). The grounding gates (plant/swing-release speed, conform window, step fade, reference-lag clamp, platform hysteresis) multiplied by model scale while the transform noise they reject (netcode correction blending, physics rest jitter, terrain-sample error) is absolute — at scales ~0.3–0.4 the gates fell below the noise floor and the plant/free verdicts flipped per frame (visible leg vibration). Every scale-multiplied gate in foot-grounding.ts now clamps to an absolute noise floor (max(value × scale, floor), floors derived from the file's noise discipline); the conform-lift ownership gate gained enter/exit hysteresis (exit rides the swing-release lift margin, matching the locked path's release threshold); and clip-less visuals (statues) get leg-bone baseline restore mirroring the existing hips mechanism, so the pass never reads back its own solved pose as the "animated" prior (the statue feedback loop — a statue's foot corkscrewed on slopes). ik: { feet: false } now also returns a clip-less pose fully to its authored pose (legs + pelvis residue cleared). Scale ≥ 1 behavior is bit-identical (floors sit at or below the scale-1 gates; pinned by the existing test vectors).
  • Doc generation: the @tomeapi docstring extractor kept dropping any line that starts with the tag, so single-line /** @tomeapi description */ docstrings extracted as empty — the IKSpec foot-grounding doc never reached Savi's surfaces. The extractor now strips the tag and keeps the text, the api-reference ik entry's feet clause is generated from the IKSpec docstring (build fails if grounding goes undocumented), and the always-on prompt's long-tail line names auto foot grounding next to ik.
  • Cold magic-CDN assets now survive player-path 524s: kiln detaches generation lifetime from the request (a Cloudflare-killed connection no longer aborts the in-flight cook), and engine fetchers opt into 202 + Retry-After via x-magic-cdn-async instead of holding ~100s doomed connections. Renderer classifies 202 as "generating" (info log, cooldown retry at Retry-After, no failure attempt recorded) — renderer-asset-service.ts, loaders/texture.ts, loaders/gltf.ts, new magic-cdn-async.ts probe.
  • Pointer-lock management is deslopped to one owner: effective lock state is now a pure function of (authored camera intent, visible cursor-needing UI, input mode, device capability, page visibility) reconciled edge-triggered against the browser, replacing nine accreted override/suppression flags, two parallel preference-resolution paths, two duplicate engaging-click suppressors, and a dead duplicate of the Tome UI cursor machinery (mountTomeUICore et al). Engine-initiated unlocks no longer mis-arm the browser gesture gate, so authored pointerLock flips and UI-close relocks never demand a pointless extra click.
  • Live spec flips of camera.pointerLock now propagate unconditionally (previously sticky unless a camera transition happened to run); cameraApi.hideCursor()/showCursor() now hold on every camera — including always-lock ones — until the camera changes; setCamera(config, { persist: true }) durably folds a camera patch into spec.camera (session-only without it); a respawn no longer clobbers a player's runtime camera override.
  • Starter specs no longer pin pointerLock: true explicitly (the 3D custom-camera default already locks), so converting a starter to a 2D place frees the cursor mechanically; the mouse-driven-orbit camera classifier reads the resolved pointer-lock value so flagless starters keep identical 3D mouse feel.
  • Prediction quaternion compares are double-cover aware end to end: q and −q are the same rotation and can never book a mispredict, a correction, or a resim. The angular gate (1 − |dot| ≤ ε, mismatch-detector.ts) now also backs the per-component machinery: mismatch detail rows for transform/world-rotation (vectorBlend quat axes) and physics/body-state.rotation diff on the server's hemisphere, and the drift/push/skew classifier hemisphere-aligns client-timeline samples before leaf compares (a client write that only flips hemisphere is not a converging write; a ±k-tick skew match is not denied by a sign flip). Fixes the prod phantom-drift storm on settled ragdoll entities: world-rotation "drift" with every Δ exactly 2× the server component, 18 corrections/s and ~96 ms/s of resim CPU correcting rotations that were never wrong.
  • Prediction fast-follow (#7108 review nits): the ack ring now floors its capacity at the resim window (RESIM_SEED_MIN_CAPACITY_TICKS, same source as the prediction oplog rings) instead of a min-30 clamp — at low RTT a mismatch anchored up to MAX_RESIM_TICKS back could fall off the ack ring, and the lookup miss degraded the replay to the replayFromTick = mismatchTick fallback (graceful but lossy). Bounded memory bump: at most 31 extra acked-tick entries per client. The warm/cold seed-lane equivalence pin is also strengthened (state-dependent replay, out-of-scope bystander rows, quaternion double-cover) — it exposed a real, bounded divergence on the cold lane (current-world values stamped into the mismatchTick-anchored base for rows outside the rollback scope), now pinned unweakened as a known-failing equivalence assertion.
  • Prediction resim-storm hardening, from the renderer-OOM trace deep-mine (sim worker at 93% duty, 255MB/s allocation garbage, catch-up ticks at 207–245ms): (1) the prediction oplog rings (client and server) now floor their capacity at the resim window (RESIM_SEED_MIN_CAPACITY_TICKS = MAX_RESIM_TICKS + 16; previously the 500ms RTT-derived floor kept ~15 ticks at 30Hz), so a replayable mismatch always seeds via the warm rebase-in-place lane — the cold full-projection reseed (618ms/fire in the trace) now runs at most once per miss epoch instead of on every correction; (2) the replay path stops shedding garbage: terrain-chunk reconcile policy cached per registry, removal/despawn sweep arrays pooled, and projection-value clones take a flat fast path for primitive-only arrays/objects (no WeakMap, no per-element graph recursion — 72→18 ns/op for {x,y,z}, 65→7 ns/op for numeric arrays); (3) a deterministic resim work budget (leaky bucket: 3 replayed ticks per live tick, 3×MAX_RESIM_TICKS capacity) defers plain-mismatch corrections when a storm outruns it — whole corrections are deferred, never partially replayed, so nothing is dropped and order is preserved; recovery lanes (cap/missing-data adopts, drop-ack rewrite, projection-reset replay, push delivery) are never gated. A sustained full-span storm now replays 6.8% of the uncapped tick volume. Deferrals are visible in resim stats (totalResimDeferrals/lastSecondResimDeferrals).
  • Tome property writes are now schema-validated at every creator-facing boundary (setProperty, setObjectProperty, dotted sub-key writes, batchSetObjectProperties, spawn()) for the crash-prone value-shaped families: particles, material, physics. The validators are the zod schemas in @spawn/tome-schemas themselves — no hand-maintained field list exists to drift, so a field added to the schema is validated at every boundary automatically (the class of miss that shipped the sizeOverLife frame-killer cannot recur inside a gated family). Two tiers derived from zod issue codes: a direct invalid_type with a concrete wrong-shaped value (the proven #7078 crash class — scalar where [t, size] pairs belong) throws a field-precise teaching error and the write never lands; everything else — whole-union failures, missing-required fields, enum/literal mismatches, out-of-range knobs — warns once per signature and rides the engine's existing leniency (adversarial review proved the engine's vocabulary is wider than the schema in unaudited places, and 3 repeated throws park a behavior script). Error messages carry the schema's own .describe() text for the failing path. Measured cost (node 26/V8): particles full spec ~5µs, material ~3µs, physics ~1µs per parse, with an accepted-object identity memo making repeat writes of a held spec object free; resim replay re-executes behaviors with fresh object literals so it validates unmemoized (~0.1–0.2ms per 20-tick rollback — in budget). PhysicsConfigSchema.collider widened to describe the runtime's real lenient contract (object/unknown-string forms loud-coerce, never hard-reject).
  • Three schema-vs-engine drifts found by the review are fixed in @spawn/tome-schemas: unknown material.kind strings (e.g. "glass") now validate down the standard-material path the engine actually applies (with a teaching warn); particles.textures accepts any string (preset aliases like "blood" resolve, other strings load as a single texture path — what the runtime always did); particles.shape gains kind: "disc" (radius/jitter/lift), now plumbed end to end through EmissionShape, the legacy emitter lowering, and the component codec.
  • Texture scripts gain ctx.atlas({ frameSize, frames | animations, fps, defaultAnimation, filter, draw }) — a frames-first sprite-atlas bake. The engine computes the grid (near-square, row-major, 1024² cap) and derives the atlas metadata (columns/rows/animations/fps) from the same options that drive the draw, so layout and frame info cannot drift. draw(g, frame) runs once per cell on a fresh frame-local 2D context (hard cell isolation); frame carries { index, animation, frame, count, t, size } with t = frame/count as the loop-clean phase. A throw mid-frame faults the bake naming the frame (frame 2 of "walk": ...) and rides the existing park/diagnostic path. The derived atlas wins over a static meta export (which stays supported for hand-laid grids); everything downstream — bake worker, hash invalidation, atlas registration, sprite animation playback — is unchanged.
  • 2D tilemap terrain is infinite by default: width/height on terrain: { kind: "tilemap" } are now optional. Omit both and tileAt({ x, y }) is a field evaluated for any integer cell — negative coordinates included — streamed as 16×16 chunks in a viewer-anchored window (the heightmap streaming posture; the server windows around all players, a client around its predicted player; colliders still build locally and symmetrically with zero wire). Chunks behind the window stay resident as a generous ring and evict farthest-first past the 1024×1024-cell residency cap; setTile/clearTile/resetTile overrides ride their replicated chunk entities and survive eviction. Unbounded maps anchor cell (0, 0) at the world origin (generator coords = floor(world / tileSize)); authored width/height keep today's bounded, place-origin-centered behavior byte-identically. getTile is defined for every integer cell on infinite maps, and the window sweep only runs when a viewer crosses a chunk boundary — a static viewer costs zero evaluations per tick.
  • api.vignette(intensity, color, opts) accepts an optional opts.decaySeconds: the vignette eases linearly back to 0 over that many seconds from the moment of the call, then clears itself (effect()-style engine cleanup timer). The decay rides the existing TomePlayerJuiceState transport as a one-time (startTick, seconds) anchor — the client derives the eased intensity per render frame, so nothing on the sim or network path changes while the decay plays. A re-call mid-decay restarts the ramp; vignette(0) still clears immediately; omitting the option is byte-for-byte today's set-and-hold behavior.