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 allplaySoundAtone-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; explicitAudioListenerentities still take precedence. playSoundcloses its open loop: behaviors can export anonSoundEnd(sound, api)hook that fires exactly once when a non-looping sound the object played finishes, withsound.durationSecondscarrying 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 genericcmd.*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.playSoundnow 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 fireonSoundEnd(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 rawsource.loop = truewrap 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
sceneHiddenAtStartoption (set by the worker browser host) and a one-waymarkSceneVisible()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_failedlog (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 workererror/messageerror/closeare 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 whosepostMessagethrows on a live handle counts as death. DefaultWORKER_HEARTBEAT_TIMEOUT_MSlowered 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) andprediction/resimulation-stats(staging, 5.0.11 gate — theResimulationStatsclass instance the client prediction loop owns). Both are nowlive-channelinresource-dispositions.ts: the exec worker can never signal clients or run the prediction loop, and both read sites already probe-and-degrade (requestQualityLandingResetfalls 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
createResourcetoken in src/ for the same latent class (function/closure/class-instance/handle-bearing values defaulting toclone): 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-telemetryserver system (schedulereveryNcadence, ~60s at the 30Hz default sim rate) folds the existing client-health mirror — the validated per-client prediction windows that already cross on theengine.clientHealthcommand every ~15s — into one room-levelresim_telemetrystructured log line (clients, mismatch%, drift/push/skew split, mean+worst corrections/s and resim ms/s, merged top-5 offender components, quaternion double-coverphantomSuspectheuristic). 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, ONEresim_recordingevent 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.tsnow 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-referenceikentry'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 toik. - 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-Afterviax-magic-cdn-asyncinstead 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, newmagic-cdn-async.tsprobe. - 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 (
mountTomeUICoreet al). Engine-initiated unlocks no longer mis-arm the browser gesture gate, so authoredpointerLockflips and UI-close relocks never demand a pointless extra click. - Live spec flips of
camera.pointerLocknow 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: trueexplicitly (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 fortransform/world-rotation(vectorBlend quat axes) andphysics/body-state.rotationdiff 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 toMAX_RESIM_TICKSback could fall off the ack ring, and the lookup miss degraded the replay to thereplayFromTick = mismatchTickfallback (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-schemasthemselves — 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 thesizeOverLifeframe-killer cannot recur inside a gated family). Two tiers derived from zod issue codes: a directinvalid_typewith 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.colliderwidened 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: unknownmaterial.kindstrings (e.g."glass") now validate down the standard-material path the engine actually applies (with a teaching warn);particles.texturesaccepts any string (preset aliases like"blood"resolve, other strings load as a single texture path — what the runtime always did);particles.shapegainskind: "disc"(radius/jitter/lift), now plumbed end to end throughEmissionShape, 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);framecarries{ index, animation, frame, count, t, size }witht = frame/countas 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 staticmetaexport (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/heightonterrain: { kind: "tilemap" }are now optional. Omit both andtileAt({ 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/resetTileoverrides ride their replicated chunk entities and survive eviction. Unbounded maps anchor cell (0, 0) at the world origin (generator coords =floor(world / tileSize)); authoredwidth/heightkeep today's bounded, place-origin-centered behavior byte-identically.getTileis 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 optionalopts.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 existingTomePlayerJuiceStatetransport 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.