Spawn

Make Games with Words

Learn about spawn
spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.0For Real3 daysengine v4.6Atelier6 daysengine v4.5Surface Tension2 weeksengine v4.4Solid3 weeksengine v4.3Groovy3 weeksengine v4.2Continuum4 weeksengine v4.1FoundationsMay 4, 2026engine v0.1GenesisApril 29, 2026
← All posts

engine v5.0.6

Engine v5.0.6

June 7, 2026

A patch in the For Real line.

Screen ripples and water stop blacking out the frame, statues are solid in solo worlds again, NPC brains stop going blind to the world around them, standing on hills actually means standing, sound effects stop going permanently silent mid-session, dark-world graphics hiccups heal themselves, and Savi can finally see which effect is which.

what's new

  • Savi can now find the ugly effect on the first try: her live view names the biggest particle effects on screen over the last few seconds — which script, which population, which texture, how much of the frame — so a giant glowing blob traces straight back to the line that drew it, even when the effect only flashes for a fraction of a second. Asking her about an effect also tells her exactly how big it renders (spawn size → peak size) without reading the math.
  • Characters in mantle worlds no longer slowly slide down hills they should be able to stand on — slopes up to the climbable angle hold firm, and minSlopeSlideAngle on the character controller is now honored by mantle just like Rapier.
  • Statues, temples, and other placed models are solid again in solo worlds — singleplayer games stopped building collision for CDN models after 4.6, so players could walk straight through objects that were solid in multiplayer.
  • Screen ripples and water refraction no longer black out the frame — shockwave pulses and water with refractionStrength render their distortion over the scene again instead of wiping it.
  • Fixed sound effects permanently going silent over a long session (and a burst of backed-up sounds playing all at once after dying or changing places). Sounds that reference a missing audio asset now fail cleanly instead of jamming the audio system.
  • Fixed games where NPC managers went blind after an engine update or mid-session: scripts that look up objects by tag (query({ tags: [...] })) could suddenly see nothing — NPCs frozen in place, delivery/wave loops silently stopped — while the objects were clearly standing in the world. Tag lookups now always see every live object, no matter how it entered the world.
›technical notes
  • Ledger #242 (the Lumengarden "fleshy blob" hunt) showed Savi has no way to attribute rendered fx pixels to the population that drew them: she hand-derived "~6 meters" from a size curve, removed the right deck, then false-confirmed a non-fix off a view_live_scene frame where the 0.16 s flash deck simply wasn't alive. Two introspection surfaces close that:
  • getFxState per-population render summary: each population now reports sinks (sprite texture + blend mode, ribbon texture, light radius — declaration order) and expectedSize: { start, peak }, the world size in meters folded from the size binding (bindings.size, falling back to init.size, default 1 m). The fold machinery (expectedFieldScalar + start/peak modes) moved from tome/fx-utils into engine/fx/expected.ts, shared with the renderer-side census. Spec-derived and deterministic — same program, same stats, on every realm; the shape change is additive.
  • view_live_scene fx census note: every successful capture now carries the top fx populations by windowed-peak screen coverage over the last ~3 s — e.g. scripts/effects/firework-burst.fx.js/flash · 3 alive · effect-soft-glow-disc · add · ~14% of frame (peak 2.4s ago) — on the same note channel as the asset-pending caveat. The windowed peak is the design law: instant censuses miss sub-second decks, which is exactly the false-confirmation failure from the investigation. Coverage instants fold per frame from both particle backends (CPU: real bounds + Σ size² gathered inside the existing snapshot loop; GPU: readback alive counts × folded peak size at the effect anchor, until the bounds readback lands) into a 6×0.5 s bucket ring per population (renderer/three/fx-census.ts). Renderer-only and parity-safe — no sim-observable state; the dt-accumulated clock never touches ticks. FxCompiledProgram (client plane, never networked) now carries the emitter's script path so the note names the .fx.js the deck came from.
  • Census coverage is an additive estimate (one view depth per population, overlap ignored, clamped at 100%); orthographic (2D) games skip accumulation. Light-only populations are not censused — they paint via illumination, not sprite quads.
  • Mantle's character controller slid down every walkable hill (ledger #246): the motor feeds a small downward gravity displacement every grounded tick, and the collide-and-slide plane solver projected it onto the slope plane (v' = v − (v·n)n), turning it into a downhill tangential creep that Rapier never produced. Rapier's handle_slopes deletes the gravity-induced downhill tangent on non-slip slopes (angle ≤ minSlopeSlideAngle, default 45° — same as its maxSlopeClimbAngle default); mantle had no equivalent.
  • The mantle plane solver now ports those exact semantics: walkable contact planes at or shallower than minSlopeSlideAngle are marked non-slip, and when the slid velocity's tangent points downhill (slipping) while the horizontal input does not itself point downhill (!slipping_intent, Rapier's terms), only the cross-slope tangent survives. Standing holds, cross-slope walking keeps its speed without drift, intentional downhill walks and uphill climbs slide exactly as before, and slopes steeper than the threshold still slide. Too-steep contacts are never non-slip (Rapier's is_wall precedence), so steep-slope and wedge behavior is untouched.
  • minSlopeSlideAngle from the character controller config (already replicated and wire-encoded) now reaches mantle as a new minSlideCos config lane on the CC table, defaulting to cos 45° like Rapier; maxSlopeClimbAngle keeps its own independent lane. The lane is config-derived (re-applied from the replicated component on both sides), so client/server cc math stays bit-identical and snapshots/hashes pick it up by table construction.
  • New slope-hold suite pins the behavior on both engines: mantle holds at 10/20/30/40° under grounded gravity within 1 cm over 60 ticks and still slides at 50°, with a mirrored raw-Rapier KinematicCharacterController block proving the same scenario as the parity spec.
  • Singleplayer games lost all collision on preplaced CDN-model static objects (ledger #254, found on abbi's hub via the #245 investigation): the authoritative collider pass never processed a single entity, so PhysicsColliderSource/PhysicsColliderMesh were never written — no placeholder, no hull, players walked through statues that were solid in multiplayer. Worked on 4.6.
  • Root cause: the collider-assets incremental dirty-state (COLLIDER_DIRTY_STATE_BY_WORLD) was keyed by world alone. In singleplayer, glue remaps the server collider pass (physics/collider-assets/server, mutateSource) into the client world alongside the client cache-cooking pass — both in netIngest, client at order 3, server at order 5, sharing one world. The client pass ran first, drained the shared dirty set and stamped the manifest signature; the authoritative pass then saw nothing to do, every tick, forever. The regression window opened when the Mantle merge made the client pass always-on and incremental (it was debug-gated and full-scan before, which is why 4.6 was immune). Multiplayer was never affected: distinct worlds per side never shared the cursor.
  • Fix at the mechanism: the dirty-state/retry-probe cursor is per-pass bookkeeping, now keyed by (world, side). Each pass tracks its own dirty set and probes; fetch dedupe and failure backoff stay world-keyed on purpose (one fetch per cook request per world is correct when the passes share a world). No singleplayer special case, no change to glue or system registration.
  • Repro test drives the production singleplayer wiring (glue(features, "singleplayer")) against one world and pins the multiplayer contrast twin (collider-assets-singleplayer.test.ts); red on the parent commit, green with the fix.
  • Every viewport-share grab-pass blacked out the entire frame while alive (ledger #253, P1, live on prod since the 5.0.3 09:52Z promote): objectApi.shockwave (taught in the fx/combat skills) and water with authored refractionStrength > 0 wiped the whole frame for every frame the effect existed — ~2s per shockwave pulse, solid black for sustained re-pulsing (abbi's Bellona box, #245 issue 2). Worked on 4.6.
  • Root cause: the #228 wave-0 empty-frame optimization set transientMsaaColor on the post chain's scene pass (fork storeOp:'discard' on the 4xMSAA color) under the invariant "no later pass ever loads the MSAA contents". One path violates it: viewportSharedTexture materials feed themselves by interrupting the scene pass MID-FRAME (ViewportTextureNode.updateBefore → copyFramebufferToTexture ends the pass, copies the resolve, resumes with loadOp:'load'). The interrupted pass-end discarded every sample drawn so far; the resume loaded zeroed memory; the end-of-pass resolve wiped the frame. The storeOp is baked in at beginRenderPass time, so by the time the grab runs nothing can save the pass — the decision has to be made before the pass begins.
  • Fix: the renderer knows its materials. A live registry (engine/materials/viewport-share.ts) tracks every material whose node graph can trigger the grab — shockwave and refractive water register in their factories, creator TSL (material scripts can require("builtin/tsl") and reach viewportSharedTexture) via a build-time node-graph walk in buildScriptedMaterial — and each releases itself on material.dispose(). The post chain re-decides transientMsaaColor every frame from the registry: frames with a live consumer pay the MSAA store (exactly the 4.6 behavior), every other frame keeps the #228 discard win. No fork change; the fork's beginRender already re-reads the flag per pass.
  • Pinned red→green at three levels: the fork pass-structure contract (a pass begun storing survives the mid-pass copy — storeOp:'store' at the break, loadOp:'load' on the resume — and the begun-discarding poison pair is documented as the exact 5.0.3 wipe), the engine seam (the scene pass flag drops while a shockwave material is live and restores on dispose), and the factory registrations (shockwave, refractive water, creator TSL scripts; default water and non-viewport scripts never register, preserving the empty-frame win).
  • Clip ids that resolve to no URL and no manifest entry now terminally fail after a poll budget instead of pending forever; the renderer releases the owning voice slot (the wedge behind session-long SFX silence — ledger #256, residual of #213/#214).
  • Targeted/broadcast juice deliveries (audience: place/player/nearby/all) now tag their one-shot sound and particle proxy entities with the receiving player's current place, so place-travel sweeps reclaim them; they previously spawned place-less and immortal.
  • One-shot sound entities that never release within 30s are reaped, so a saturated voice pool can no longer queue a fight's worth of unplayed SFX and blast them out later.
  • Audio asset registration (clipHandle) clears terminal-failure state, giving late-registered or fixed assets a fresh retry budget.
  • Behavior query({ tags }) could return empty while the queried entities demonstrably existed (ledger #257, jissi's Zomburger on 5.0.5 singleplayer: horde-manager's own heartbeat read pool: 0, car: false every frame while a run_script full-scan in the same world saw all 300 pooled zombies and the vehicle — zombies frozen standing/mid-swing, the delivery loop dead behind its if (!car) return gate, and the world-clock's restart guard re-firing off the "missing" vehicle sample).
  • Root cause: the tome tag index was maintained only at tome call sites (interpreter spawn, ObjectAPI spawn/destroy/place moves). Entities whose TomeTags/PlaceMembership are written through raw world ops — room-delta ingest (world.spawn + world.add), controlled-entity sync, any future path — never entered the index. Multiplayer clients dodge this by skipping the tag index outright, but server worlds and singleplayer clients (the authority) trust it: getCandidateEntities/peekTagCandidateUpperBound treat a present-but-blind place index as authoritative and return []/0 with no full-scan fallback, so one unindexed write path blinds every behavior query in the place.
  • Fix: the tag index is now component-hook-driven (setupTagIndexHooks in tome/feature.ts, the exact shape of setupSpatialIndexHooks beside it): TomeTags add/set/remove and PlaceMembership add/set/remove maintain the index for EVERY write source, including despawn sweeps (component-remove hooks carry the previous payload — new removeTagsFromTagIndex consumes it since the component is already gone at hook time). The existing call-site updates stay: they are idempotent against the hooks, and transaction-overlay worlds (which fire no base hooks until commit) still rely on them for staged-index consistency.
  • Pinned red→green: a singleplayer client whose zombies/vehicle arrive via the snapshot-ingest write shape (raw spawn + adds inside an ingest window) now has its manager behavior find them via query() (tag-index-hooks.test.ts — red read 0 where 2 zombies + 1 car existed, the prod signature); plus tag-change / place-move / despawn index-consistency coverage through raw world ops.

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

For Realengine v5.03 daysAtelierengine v4.66 daysSurface Tensionengine v4.52 weeksSolidengine v4.43 weeksGroovyengine v4.33 weeksContinuumengine v4.24 weeksFoundationsengine v4.1May 4, 2026Genesisengine v0.1April 29, 2026
← All posts