Spawn
playmakespawnjam
⌘K
visitorclaim namesign in
sign up
playmake
spawn / aboutwhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.2LumeJul 10, 2026engine v5.1ConnectionJun 18, 2026engine v5.0For RealJun 4, 2026engine v4.6AtelierJun 1, 2026engine v4.5Surface TensionMay 22, 2026engine v4.4SolidMay 15, 2026engine v4.3GroovyMay 13, 2026engine v4.2ContinuumMay 9, 2026engine v4.1FoundationsMay 4, 2026engine v0.1GenesisApr 29, 2026

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

Lumeengine v5.23w agoConnectionengine v5.11mo agoFor Realengine v5.01mo agoAtelierengine v4.62mo agoSurface Tensionengine v4.52mo agoSolidengine v4.42mo agoGroovyengine v4.32mo agoContinuumengine v4.22mo agoFoundationsengine v4.12mo agoGenesisengine v0.13mo ago
← All posts
← All posts

engine v5.2.12

Engine v5.2.12

July 31, 2026

A patch in the Lume line.

what's new

  • Buttons work on phones now. Action buttons Savi builds — especially in scrollable lists — used to die in mobile touch handling: you'd tap and nothing happened. The engine owns the tap now: a tap fires exactly once, a real scroll stays a scroll, and every existing game heals with no edits.
  • One typo can't freeze a character anymore. A script error used to shut down the whole script after a few strikes — jump broken meant walk broken until the next edit. Now an error skips just that one call: everything else in the script keeps running, the broken path keeps being retried, and the fault report names exactly which hook failed and says the rest of your script is fine.
  • Rejoining a busy room brings back the whole game now. A reconnect used to rebuild only the authored level — anything spawned during play (NPCs, dropped loot, bots) could silently vanish. Now the running world itself is reconstructable: refresh mid-game, or come back to a room everyone left, and everything that was alive is still there with its last state.
  • Leaderboards are game data now. The separate window.spawn.leaderboard service is gone — Savi builds leaderboards with api.sql in your game's own database, where they can be shaped however your game needs (per-mode boards, weekly resets, friend filters). Games still calling the old API get a clear message telling Savi the one-line migration instead of failing silently.
  • Savi's tools stop wedging on games that move players between places. When a match start pulled everyone out of the lobby, Savi's edits to that game could hit 20-second timeouts for as long as the match ran — the room had nobody left "standing in" the lobby to run them. The headless room shell now hosts every place the game needs, so edits run immediately no matter where the players went.
  • Characters can finally SIT — reliably. Savi has one explicit switch that stops the engine from fighting a seated pose (no more captains hovering over their chairs or drifting into the air), and she knows how to make a seat survive server reloads, so your bridge crew is still seated when you come back.
  • Multiplayer rooms spend a lot less bandwidth per player. Motion and state now ride a compact binary wire, negotiated per connection — older clients keep the exact JSON they always spoke, solo play sees no difference, and the numbers your game reads are bit-identical either way. Full rooms just get cheaper and smoother on the wire.
  • Terrain works fully in busy rooms now. Sculpts and terrain edits reach every player in relay-hosted rooms, and the ground loads from its saved baseline even when nobody's been in the room — no more flat or missing terrain that only appears after someone edits something.
  • (Already live since the previous update — the notes just never said so.) Hold-to-interact, cooldowns, and any "stamp the time now, check it later" logic now work for every player, not just the one hosting the place. A crate that only the host could pry open, a pickup only the host could claim, a cooldown that never expired for visitors — all the same bug, fixed under the game.
›technical notes
  • Engine-owned tap primitive — authored action buttons stop depending on browser click synthesis (r-e6fef5fc; the laurolandia dead-buy class). onclick="sendAction(...)" buttons died in the user agent's touch gesture disambiguation: pointerdown→pointercancel over a scrollable authored list means no pointerup, no click, zero actions. (onpointerup — Savi's field workaround — is only half a fix: pointerup never fires on canceled gestures either.) frame/tap-actions.ts: the frame runtime owns the tap verdict with ONE constant pair (TAP_MAX_MOVEMENT_PX=10 / TAP_MAX_DURATION_MS=500). An in-budget pointerup fires at finger-up as a synthetic click with full authored fidelity; the browser's own compat click is swallowed — one activation per gesture, structurally. A within-slop pointercancel takes a deferred scroller-displacement verdict: a real scroll stays a scroll, a cancel that scrolled nothing is a stolen tap and fires.

  • Binding surface: [onclick*="sendAction"] auto-upgrade (existing games heal with no edits) + declarative data-action / data-action-payload. input-forward skips engine-synthetic clicks so the host click stream is byte-identical to today's. Red-first receipts (tests/browser/suites/mobile-authored-ui-actions.pw.ts, both engines): stolen-tap pins were ZERO arrivals pre-fix and deliver post-fix; a scroll flick from a button stays zero-false-fires with the list really scrolling; clean tap / micro-drag / long-press / morph-under-finger deliver exactly once.

  • Hook fault boundary — a creator-script throw no-ops that call, never the script (r-1ad83017; ravi's laurolandia dig cdbf3634). The specimen: a wrong-signature spawnFx on the jump path of a single-script pony marked the whole scope faulted on the FIRST throw — update, onInput, and sound all skipped, durably parked after 3 identical strikes. One typo = can't walk for ~40 minutes, while Savi verified green on the server plane. The cut: the fault ledger stops meaning "a hook threw" and starts meaning "this scope must STOP running" (a stop-verdict). containBehaviorHookThrow is the one catch body for every creator-hook throw site (composed callEach entries, update, onInput, onInteract, onCollide/Trigger/Liquid/JointBreak, onSoundEnd, onNoise, camera hooks, timers, job callbacks, event handlers, onSpawn/onDestroy, onControlBegin/End, onPurchase): the throw no-ops that call ONLY — no fault minted; the script's other hooks and its own later calls keep running.

  • Loud, never spammy. Ops log sees every occurrence (boot-scale class dedupe), then ONE report per (hook, script, error-class) signature per 30s window rides runtime log + DM + the client fault wire, carrying "contained: that call was skipped; the script's other hooks and later calls keep running" — the fault report names the hook. A valueless throw gets a substitute message instead of silent no-op forever.

  • NOT absorbed: ScriptBudgetExceededError keeps fault+park (a wedge costs ~1s per re-invocation; the budget watchdog relies on the ledger to not re-run it), and throwing updates now bill the budget watchdog since they re-run. Rapier corruption / codegen-pending stay classified, never blamed on the script. Surviving fault mints are each a genuine stop-verdict: wedges, subscription liveness verdicts, god-mode editor/visual layers, the stuck-job backpressure lane. Red-first pin: behavior-hook-containment.test.ts reproduces the specimen (watched red at walked=1 on the old code).

  • Keyed heads — the relay journal stops deleting the live world; reconnects and fresh joins rebuild the runtime world (kernel half of #11655; dump efd83a2b, MechaBlade room-1). Root: self-compaction prefix-deletes the journal behind a SPEC-ONLY pointer (the authored level, a document that predates the session), so runtime-spawned entities — which exist ONLY as journaled create rows — were unrecoverable after one compaction cycle; the peer-witness lane papered over it only while a witness was online. The engine now SPEAKS the keyed-heads grammar the relay learned: producers stamp an opaque key on batched STATE rows and the relay upserts each into a heads overlay at append time (keep-max(seq)-per-key, O(1), engine-blind); a FRESH dial drains heads(≤C) then journal(>C) as one ascending stream, so journal+heads alone reconstruct the running world — witness or no witness, empty room included.

  • The producer (relay-wire-session emitJournaledStatePart): journaled StateDeltas re-chunk into one-total-statement-per-key rows — creates → key + tomb (scrub-and-insert; spawns and rebirths are one shape), non-motion updates → one row per component carrying the full value, deletes → tomb-only. Per-flush key coalescing sheds write amplification; rest stamps ride the same lane, upgrading the motion lane's loss story to "durable past compaction". The fold constitution keys NOTHING fold-relevant: member avatars, presence-stamped messages, commands, and events stay keyless legacy.

  • The join edges: F10 failed-resume answers a new redial-fresh verdict (cursor reset, keyed outbox pruned, re-dial with resume=0 — the only dial shape whose welcome floor serves heads); the live flip rebases the cursor to the welcome's catchupThroughSeq; the host-stamp gate runs on the LIVE phase only (gating history on a mid-construction host table dropped the entire hosted world at every fresh join — the dump's "names nobody" lines). Restate-on-adoption (relay-world-restate.ts): a host seat taken at a new epoch re-journals its place's unowned remainder as keyed creates, closing the deposed-epoch stale-heads window and self-healing keyless journals at the first post-deploy adoption. Pinned end-to-end: kill-all-clients world reconstruction — bots return with last-written state, dead bots stay dead, no avatar resurrection (relay-late-join-live-layer.e2e).

  • window.spawn.leaderboard + leaderboard:submit removed — api.sql subsumes the platform leaderboard (jacob 07-28, thread 1785296593; r-a20d11f0). Removal lands as TEACHING refusals because real callers exist: the kernel client's window.spawn.leaderboard is a throwing getter naming api.sql + the one-line migration (the RPC trio leaves the iframe method union); the kiln parent answers leaderboard.submit/top/around RPCs with the same teaching error (covers engines pinned pre-removal) and its sdk object drops the leaderboard block; leaderboard:submit becomes a registered builtin tombstone settling { ok: false, error: "leaderboard_removed_use_api_sql", message: <migration> }, and the relay bridge settles the same tombstone data (kernel parity); the realm allowlist shrinks to [assets.uploadImage, user.upgradeGuest] with the frame shim teaching api.sql on any leaderboard.* touch.

  • The env.leaderboard capability rail dies whole: createSdkLeaderboardEnv, JobLeaderboardEnv, WorkerdScriptedJobIo.leaderboardSubmit, the RoomJobIo lane, the room-seam presence/cadence gate (leaderboard-submit-gate.ts deleted), both seam gate blocks; BUILTINS_REQUIRING_HOST is empty. gsdk-types drops leaderboard from the client SDK type (server type untouched).

  • Teachers edited same-turn: skills/leaderboard.md rewritten around the api.sql board (write/read lanes, every networking mode); game-ui + jobs + data-and-saves scrubbed; V2-leaderboards.md wears a removal banner; the read-rail drift guard now pins the successor teaching. Controls held: api.sql untouched; leaderboard-using tests migrated, not deleted; kiln HTTP routes + DB tables + gauntlet + gsdk-server stay for a named follow-up teardown.

  • Multi-place sidecar — one room_host shell hosts every place Savi's work needs (#11562, dump 237ad0fc). A place-less run_script resolves to the spec's defaultPlace, and a place with no bodies can elect no host — so when a match start pulled every member (headless shell included) out of the lobby, the exec lane wedged permanently: every member logged "not mine to run: host=UNELECTED", kiln burned its 20s deadline into infra-cut 504s, and the summon machinery correctly saw a healthy attached shell it could not help with. Fifteen minutes of dead tools on a perfectly live room. The structural fix: hosting is no longer welded to body residence for the room_host shell.

  • Claims are the floor under the election. RelayPresenceFact grows an optional hostPlaceClaims restatement (room_host class envelopes only; sinceSeq anchors mirror presenceSeq's compaction convergence). The fold seats a claimant only when no eligible resident exists — residents always outrank. The shell states a STANDING defaultPlace claim at bootstrap and claims other spec-declared places LAZILY: an exec targeting an unhosted place is parked, the claim journals, and the same exec releases when the seat folds (~1 RTT). The "at most one hosted place per client" invariant dies: getLocallyHostedPlace → plural getLocallyHostedPlaces/isLocallyHostedPlaceId/getLocallyHostedPlaceEntry across every consumer; the uploader drain stamps one message per seat; physics adoption lanes key per seat.

  • Receipts: 10 e2e scenarios including a literal incident replay (bodies pulled to main, lobby seat holds, the exact 504'd exec answers), compaction convergence, forged-claim refusal; live on the deployed dev relay — place-less boot 16s cold, lazy place claims ~600ms, held-seat repeat 477ms, an undeclared place refused honestly in 6s with relay_exec_no_host_tick. One sidecar, three simultaneous seats.

  • Explicit NPC locomotion suspend — the seat primitive (ledger 1892, incident f6c70bbb). A seated pose used to fight three engine systems at once: npc-loco's weight-1 idle channel rewritten every tick under any script-owned Sit channel, the mover's per-tick ground snap dragging a body seated above the nav floor down (or up onto the chair collider — the field's "kaptajnen sad i luften"), and per-tick facing writes. npc: { locomotion: false } is the explicit switch that releases the body from all three: the nav solver and mover skip the agent (parked intents spend no path quota; a seated Y holds exactly), facing writes stop, and the locomotion system REMOVES its npc-loco channel (merge-delete — script-owned sibling channels survive; an empty mixer drops the component, mirroring updateChannel(name, null)). Perception (canSee/onNoise) stays live. Only exact false suspends — the engine never infers a pose hold from channel weights. Round-trips through getProperty("npc").

  • Park/re-host protocol pin (ledger 1839 kin). New e2e pin: a runtime updateChannel write (DrawMixer component) survives the unhosted-place freeze byte-for-byte and redelivers intact on the re-host wake reset; it dies only when the empty place UNLOADS (entities destroyed, re-materialized from spec). Runtime channels are load-scoped; the durable expression of a pose is spec state (properties.mixer, properties.npc) — now taught.

  • Teaching: 3d-animations.md gains "Seating a character" (the ?animations= Sit mint gate, the suspend, parent-with-LOCAL-offsets, the ik: { feet: false } opt-out and its whole-value-replace spread, runtime-vs-spec durability); npc.md's properties block names the switch. Three increments of the binary-protocol ladder (apps/cf-relay/docs/binary-protocol.md), one lane (#11645 → #11650 → #11656):

  • Envelope v1, negotiated per socket (#11645, increment E2 — plus E0's encode-once fanout: relay CPU per fanned motion-1 frame 13.19µs → 3.93µs). The frame envelope (op/seq/identity framing) goes binary; payloads stay JSON at this rung. Negotiation is the relayBin=1 dial advert × the welcome's transport form as the answer — settled at the 101 relay-side, before first client egress, fail-closed in both skew directions, zero probing frames (a WS subprotocol offer would hard-fail browsers against already-deployed kernel-tier shells). cf-kernel carries the mirror codec (relay-wire-binary.ts — mirrored, never imported; golden-vector twins pin both sides byte-for-byte), per-generation session wire format, and sniff/ingest for binary relay frames. Journal storage-class split: TEXT = JSON payload, BLOB = binary register — the class IS the format tag, no migration; catchup/retransmit re-wraps per socket (BLOB to a JSON socket = base64 + payloadEncoding). Platform truth pinned: workerd's output gate captures ws.send(view) by REFERENCE and materializes at gate-open — binary frames copy once per frame per format (binary-envelope.workerd-test.ts).

  • Payload registers: StateDeltas + host-tick rows (#11650, increments E3+E4) — the ~90–97% of hot-frame bytes the envelope left alone. Registers open with their own payload-version byte (0x7B reserved forever as "this payload is JSON", so mixed-vintage journals decode row by row) and are self-contained per frame (journaled payloads never carry cross-frame state — compaction makes a session dictionary structurally unsound): a version-pinned STATIC component name table + per-frame string tables + a self-describing recursive value grammar with vec3/quat fast paths; unknown component names inline once and alias within the frame, so new components never need a new register. The equivalence gate is MECHANICAL — deep-equal, no tolerances (corpus includes f64 physics, NaN/Inf/−0, unicode, unknown components). One shared parse door (parseRelayOpaquePayload) at peer-apply, the election fold, the host-tick gate, and the SPEC lane; unknown registers warn-drop loudly. An outbox entry minted binary re-projects LOSSLESSLY to JSON text when the next generation is JSON. Measured: motion-1 498→157B, motion-8 3237→950B, 10-row mix 3153→1040B.

  • Motion-delta lane: lossless deltas + keyframes on the EPHEMERAL wire (#11656, increment D1) — zero relay changes; the relay cannot tell a delta from a keyframe. The one STATEFUL register, legally: EPHEMERAL frames are never journaled, so self-containment scopes past them; keyframes (cadence K=30 ticks per author) bound the chain. Delta rows carry only components whose value changed since the author's own last motion-lane send; they apply as the partial LWW component writes update rows already are, so the applied stream is bit-identical to the full-row lane's — pinned at every tick of a seeded 200-tick trace (full-row JSON 259,895B vs 23,831B, 10.9×). Fail-closed at every seam: unresolvable aliases discard (never guess — the entity holds its last pose ≤1 keyframe, then re-bases), a chain-epoch guard makes a stale alias table unusable, a refused/failed send marks the whole chain keyframe-due, and journaled-restatement/create/delete EVICTION means a delta can only skip components the receiver provably holds. keyframe-please gives joiners a fast re-base, gated on binary payloads so old-build peers never meet the vocabulary.

  • Terrain edits cross the relay tier (#11644; the 2026-07-30 staging "terrain missing until modified" class). Terrain-edit state had zero working lanes on relay rooms: rail.voxelEdit/rail.fieldDab dropped at every peer, the world-sync statement excluded terrain, and the durable _spawn_chunks baseline had no loader without a server world. Three lanes now exist: peer apply (relay-peer-apply — senderOwnsSource authority gate, the same engine funnels as the kernel rail, live + catchup phases; parse/cap cores extracted into shared wire modules consumed by both sides, server behavior byte-preserved; per-tick budgets deliberately NOT mirrored at peers — observer-relative ticks would fork the journals); world-sync terrain statement (the default-place host states its compacted chunk journals inside the existing request/authorship envelope); durable baseline READ (cf-edge GET /terrain/{variantId}/{roomMode} — the /spec route's sibling — plus relay-terrain-fetch and a parallel join-time fetch that is never join-fatal).

  • Install primitive: rebaseTerrainChunkEditsOntoBaseline — baseline history hydrates UNDER live session edits (never stamp-compared across lineages); idempotent, order-safe, convergent across observers. The durable WRITE half (relay rooms flushing back to _spawn_chunks) is deliberately absent: it is the persistence program's recorded open ruling (persistence-one-verb.md §15, elected-host trust) and must not be decided as a side effect here.

  • Shipped in 5.2.11 (#11631 was inside that release's re-sha'd build head) — but 5.2.11's notes never taught it; taught here so the record is honest. Behavior on this version is identical to 5.2.11.

  • api.seconds() and api.getTick() now read the ROOM's clock instead of the local machine's tick counter. Both add back the sim time this machine's ticker shed (backlog clamps past maxStepsPerFrame, plus the wall-clock gap a frame-clock reset discards on a hidden-tab resume), so every machine in a room reports the same game time. Engine-internal scheduling — timer due-ticks, lifetime deadlines, sound-loop and vignette anchors — keeps using the local tick, unchanged.

  • FixedStepTicker accumulates getShedMsTotal(); the client runtime publishes the correction as RoomClockShedTicksResource each step and zeroes it on every authoritative rebase. Server worlds and singleplayer never set it, so their readings are byte-identical to before.