Spawn
spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.1Connection2 weeksengine v5.0For RealJune 4, 2026engine v4.6AtelierJune 1, 2026engine v4.5Surface TensionMay 22, 2026engine v4.4SolidMay 15, 2026engine 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

Connectionengine v5.12 weeksFor Realengine v5.0June 4, 2026Atelierengine v4.6June 1, 2026Surface Tensionengine v4.5May 22, 2026Solidengine v4.4May 15, 2026Groovyengine v4.3May 13, 2026Continuumengine v4.2May 9, 2026Foundationsengine v4.1May 4, 2026Genesisengine v0.1April 29, 2026
← All posts
← All posts

engine v5.1.12

Engine v5.1.12

July 7, 2026

A patch in the Connection line.

what's new

  • Fixed a reconnect trap that could freeze a player's world forever: if the room restarted while a tab stayed open, that player could get stuck asking for a world snapshot they could never read — repairing hundreds of times and never recovering without a reload. Recovery now works in one clean step.
  • Fixed a timing window where changes Savi made right as a place was loading could report success but never reach players or the save — the room quietly ran a different world than the one on disk until a restart. If the two ever drift again, the engine now says so instead of staying silent.
  • Saving your changes is much harder to lose now: if the save path hits server hiccups (timeouts, gateway errors), the engine keeps retrying in order until it lands — and if it truly can't land, Savi is told loudly. Before, a bad enough hiccup could quietly drop the change and you'd only notice much later.
  • The "world stopped updating" self-repair no longer thrashes: repair attempts now back off and stop at a bound, and when repair can't win, the engine reports exactly why — instead of silently rebuilding a player's whole world every few seconds for half an hour.
  • Script-generated shapes now show up for every player: geometry a script invents at runtime (custom meshes, procedural props) used to exist only for the player whose world spawned it — everyone else stared at nothing while the engine retried forever. Now the recipe travels to every client and each one builds the shape locally.
  • Squash and shake effects can no longer permanently deform or teleport an object when timing goes wrong — they're pure visual juice now, and the object's real size and position stay untouched no matter what interrupts the animation. Squash also lands a touch crisper. (Worlds already stuck with a deformed object: restart the room once on this version and the fix sticks.)
  • You can now say what a rotation rotates around: pivot: "center" to spin in place, "top" to hang and swing, or a door hinged at its edge — on any object, with no invisible hinge-parent tricks. Colliders, nameplates, and anything attached follow exactly, and it works the same on parents and children.
  • 2D sprites can finally bank and tilt: a lobbed projectile tips its nose along the arc, a leaning character actually leans — sprites now show the rotation their physics always had, turning around the same anchor point the art and hitbox share.
  • Objects spawned by scripts now behave exactly like objects placed in the world from the start: NPC behavior, custom 2D colliders, and IK settings (like turning off automatic foot-grounding) all land identically either way — no more "it works when it's in the spec but not when a script spawns it."
  • Fixed a race where a command aimed at a just-spawned object could reach the server before the object itself existed there — spawn-then-command now always arrives in order.
  • Switching camera styles no longer leaves your body wrongly invisible (or wrongly visible): the old camera's "hide me" setting dies with the camera that set it, in both directions — first-person hides you, third-person shows you, every time, without restarting.
  • Looping sounds now stop when the thing making them goes away: destroying an object, swapping its sound clip, or moving to another area no longer leaves a loop droning forever until the page reloads — and api.stopSound actually stops the sound.
  • Savi's scripts work on the first try regardless of import/export style: both dialects (export function and module.exports) are accepted everywhere — behaviors, shared modules, UI, effects — and when a module still fails to compile, the error explains exactly what to change instead of a cryptic parser message.
  • Clearing a player's model no longer makes them invisible: players fall back to their profile avatar instead of silently rendering nothing. (Deliberately body-less characters are still one explicit setting away.)
  • One catastrophically slow session can no longer curse your game into opening blurry forever after on that browser: saved quality settings now start no lower than "everything visible," and the emergency low-quality modes have to be re-earned by actual measurement each session instead of being remembered as the default.
  • When a script can't run and the engine knows why — a behavior whose script file is missing mid-edit, or a manager paused because no one is in that area — Savi now gets told exactly what's inert and why, instead of you both staring at "my code isn't doing anything."
  • A script repeating the same small mistake thousands of times no longer floods the logs and drowns out everything else: the engine teaches the mistake once, in full, then keeps a quiet count — so Savi's view of your game's logs stays readable.
  • While we run an engine-stability push, the engine reports much more of what happens on players' screens (including renderer warnings that used to stay invisible to us) — so the bugs you feel get found from our side, without you filing anything.
  • Phones whose graphics support can't run the engine (a class of older/budget Androids) now see an honest "this device's graphics support isn't enough" message instead of loading into a broken world where sprites show but every textured or scripted material is invisible.
  • When a texture permanently fails to load, Savi can now see it in the logs (with the reason) instead of the object silently keeping a placeholder forever.
  • Custom cameras that position and aim themselves entirely from script (cinematic rigs, planet-surface cameras, shot-follow cams) now always show exactly what the script authored. Previously, if such a camera never used lookAt(), the engine's mouse-orbit camera could silently take over the view — pointing the camera somewhere the script never sent it (even through the ground). Mouse-look cameras are unaffected and stay just as smooth.
  • Scripted-geometry meshes that mix solid parts with translucent parts (a ship hull with a glowing afterburner cone, a building with glass windows) now render correctly: the solid parts stay solid — they hide what's behind them and cast shadows — while only the translucent parts are see-through. Previously one translucent detail made the entire mesh ghostly, letting internals show through hulls and turning the object's shadow off.
  • Cars can now feel their tires! Each wheel reports how hard it's sliding, how much grip it has left, how much weight it's carrying, and what surface it's touching — perfect for skid marks, tire smoke, drift scoring, and ice/dirt/road grip changes.
  • Long-running, high-action worlds stay fast: an internal history log used to get fully re-packed every single tick, quietly eating a bigger and bigger slice of each frame as a busy world accumulated history. It now only tidies up when there's real garbage to clear, so big worlds keep their speed.
›technical notes
  • Client wire session is scoped to the socket, so projection self-heal actually heals (#8180, merge 348875f707): RoomWireClientSession survived socket loss while the server's wire dictionary is room-process-scoped with ids minted in first-encounter order — a room restart under an open tab re-minted ids in a new order, and ShapedObjectMapRegistry.defineFromWire was silent-first-wins, so stale shape defs beat the replayed ones and every baseline decoded to null. Self-heal re-requested a snapshot it could never decode, forever (observed: 628 heals/25min, reload-only; second specimen 30s after a restart-rooms). Three walls, each mutation-tested red on its own: (1) awaitFreshProjection() resets the wire session with the socket (all callers are socket-death paths; a fresh attach zeroes the connection watermark → full bind-log replay, lossless); (2) beginProjectionSelfHeal() wipes the session before re-requesting, so every reset snapshot is assembled against a just-zeroed watermark and even in-place corruption heals in ONE cycle; (3) defineFromWire() overwrites on different-signature redefinition (the sender is authoritative on an ordered socket) with one loud tripwire line. Wire format untouched — encode/decode byte paths unchanged.
  • Replicated TomeSpec upserts atomically with the spec apply (#8191, merge 8f26d50943, ledger 938 persist wedge): applyTomeSpec ran applySpec, awaited the place-start hooks, then upserted the replicated TomeSpec from a snapshot captured before the await. An exec that dispatched and merged inside that window passed the exec merge gate and then had its spec writes clobbered by the late upsert — exec reports success, query() sees the entities, but clients never receive the creates and the room's spec head silently diverges from kiln until a restart. The upsert now lands in the same synchronous slot as the apply (no await between content and revision), a divergent-head detector (room.spec.replicated_head_divergent, runtime log + one DM per room) makes any residual drift loud, and the race is pinned by a real applyTomeSpec-vs-exec test.
  • Persist reconcile ladder — non-409 exhaustion re-queues + retries instead of silently dropping mutations (#8202, merge 5004ddf7ff, ledger 947): persistBatchWithRetry's non-409 exhaustion (3 attempts — kiln 504s/timeouts under load, 32 specimens in one DD day) used to DROP the batch with a log line and no resync, the last silent-divergence door in the 938/944 data-loss family. Exhausted batches now re-queue at the head and retry on a bounded doubling ladder (~155s total), preserving mutation order — the batch GROWS rather than skips, because skipping means older per-path values landing after newer ones (resurrection bugs). Terminal loss is loud: a structured fault on the engine-diagnostic rail (runtime log + creator DM via #8195's rail + server breadcrumb) instead of a quiet drop. 409 semantics unchanged (terminal-for-batch; kiln's replace-resync poke is the recovery). Accepted bounded cost, ledgered at merge: a deterministic-4xx poison batch rides the full ladder while growing. The kiln-side half of the family (#8194, all-or-nothing mutation batches) ships on kiln's own lane, not in this kernel build.
  • Projection self-heal loop is bounded + backed off, and its decode failures are named (#8195, merge d47c2ab0e7, ledger 945; live specimen: 535 consecutive self-heals, one player, ~36 min frozen world): (1) discard_flood shares the doubling backoff — the 2s floor scales by 2^selfHeals capped at 30s, so a wedged busy-world session decays to ~2 full-snapshot re-assemblies/min instead of ~24; (2) episodes bound at 8 self-heals, then a terminal structured fault (projection-heal-exhausted) on the engine-diagnostic rail (runtime log + one-time DM + server breadcrumb + page-realm console.error for DD) — a capped episode still accepts a late baseline (the client stops asking, not listening); (3) cause discrimination: undecodable_reset when baselines arrive but fail decode vs discard_flood's delivery hole; (4) decodeStateDelta's error-blind catch { return null } now records error, offset, last section, payload size, head hex, and binds read — one prod log discriminates poisoned codec state from size-scaling truncation.
  • Scripted-primitive recipes replicate; clients derive geometry on ingest (#8182, merge 90fa73cf9b, ledger 929): runtime-spawned scripted geometry never reached remote clients — DrawMesh pointers replicate (aoi) but BespokeGeometry AND TomeScriptedPrimitive were both replicate:"never", so only the spawning world held the geometry while every other client waited forever on its signature. The #713 re-request rail can only retransmit from the client's own world — it heals intra-client drops and could never heal this class, burning 3 full stream-reset rebuilds per entity per session while its diagnostic claimed transient join-load loss. Now: TomeScriptedPrimitive rides the wire (replicate:"aoi" + JSON codec + equals gate + wire-safe sanitize — the recipe is script ref + params + seed, kilobytes; derived buffers never ship), and a client-side ingest system (tome/scripted-primitive-ingest, netIngest after spec-sync) derives BespokeGeometry locally through the same deterministic path spec objects use, keyed by signature, with an attempt memo that re-arms on recipe/pointer/script-content change and never spins. Honest diagnostic split: pointer-without-recipe waits now report bespoke-geometry-underivable with the actionable truth (persist the object as a spec object); the renderer-side stalled message stops asserting unverified claims.
  • Squash/shake juice goes render-only — presentation juice never writes authoritative transform (#8206, merge 240b3dd40f, ledger 951): objectApi.squash/shake wrote real WorldScale/WorldFeetPosition through a base-capture/restore protocol whose restore was epsilon-gated on "current == applied" — any interleaved scale write meant the restore never ran, leaving entities permanently corrupted (dump-verified: pouch at 2467 x/y). The juice envelope now composes post-smoothing on the render side under the jiggle law (presentation never writes authoritative transform), verified at three independent layers: the replication drain skips juice components, the server rejects fabricated never-components, and snapshots exclude them so AOI churn cannot resurrect a stale value. The old path was itself a law violation (the sim hierarchy solve read juiced parent scale — children juiced a tick late; the new path is same-frame). Destroy mid-squash, same-id respawn, and renderer despawn interleaves all pass with zero leaks and zero phantom restores.
  • Entity-local bounds-normalized pivot for rotation/scale (#8122, merge 59300bb59d, plan p-ff53541e): pivot?: "feet" | "center" | "top" | [u,v,w] | {x,y,z} — keywords are sugar, bare arrays are bounds-normalized, objects are entity-local meters. One rigid fold (T(F + S·P − R·S·P)·R·S) honored uniformly on roots and children, in the authoritative solve, the render solve, the eager Local→World mirror, the presentation delta, and the client projection inverse. Static/kinematic bodies place their colliders on the identical folded transform; dynamic bodies ignore pivots with a teaching warn, re-derived at spawn and at body-type transitions (both directions, no stranded state). feetPosition stays the authored anchor; the live read-back orbits the pivot and everything anchored to the entity (colliders, nameplates, cameras, triggers) follows rigidly. getLocalPivot echoes the authored costume. Old parent-frame child-pivot semantics die; the taught door recipe is now ONE kinematic entity hinged by pivot: [0, 0.5, 0.5].
  • 2d-side roll / 2d-top yaw pass through the sprite pin, pivoting at the anchor (#8116, merge b182dda7da, ledger 896 / pc-7b152a33): the 2D sprite pins discarded ALL entity rotation (2d-side: identity; 2d-top: fixed −90°X) while physics honored it — a curved projectile could not bank its nose in the sprite register. The entity world rotation's in-plane twist now composes onto the base pose (Z-twist in 2d-side, Y-twist in 2d-top) on both render paths (batched writeBatchTransform + standalone syncStandalone) via one shared source, sprite-quad-orientation.ts. Yaw/pitch stay ignored in 2d-side (flipX owns facing); pivot = sprite.anchor; zero-rotation sprites are bit-for-bit unchanged.
  • Runtime spawns get the interpreter's property pipeline — npc + collider2d land (#8152, merge 6fec8fc669, ledger 906): api.spawn now derives its physics primitive via the interpreter's resolvePhysicsPrimitive (sprite hulls from authored collider2d included) and applies properties.npc, with the npc default-hurtbox decision moved into applyPhysicsForSpawn where authored physics is visible — outcome is order-independent, exactly like the boot lane. Parity pinned by a runtime-vs-interpreter structural-equality test; the enumeration guard's npc entry deleted (now wired).
  • properties.ik applies at runtime spawn (#8119, merge 9422177f82, ledger 901): applySpawnProperties had no ik branch — the value validated, rode the spec, and silently never became an IKIntent, so a creator's { feet: false } opt-out couldn't turn off engine foot grounding (the mannequin tremor). One branch covers both realms (client-realm spawns re-enter the same applier).
  • Synthetic creates flush before rail commands (#8154, merge 17bc3a3850, ledger 904 spawn-race): a rail.* Command sourced from (or targeting) a just-spawned entity departed one tick before the entity's create — behavior-update enqueues the Command mid-tick and egress flushes it same tick, but creates drained only at the post-commit envelope publish. The server rejected at resolveOwnedSource ("does not exist on the server (yet?)"), and script-side defers couldn't fix it because the flood budget drops the oldest StateDeltas under backlog while Commands survive. enqueueClientCommand now flushes a synthetic snapshot-create for referenced entities first (ledger 752 / #7715's twin, extended from SpecMutations to creates).
  • Starter first-person cameras hide the local player via the camera def, not sticky per-tick state (#8127, merge 90ae58fd79): the seeded camera.js templates re-asserted api.patchState({ _hideLocalPlayer: true }) every update; camera state survives script rewrites by design, so when Savi rewrote the camera to third-person the stale flag kept culling the local player on that client only. The templates now declare hideLocalPlayer: true on the custom camera def (existing surface, no new API) and the per-tick patch is gone.
  • Camera def replace clears the old def's _hideLocalPlayer override on the apply seam (#8150, merge 76a7b43d3e, ledger 910 — the mirror trap, found in #8127's review): top-down-style camera scripts force _hideLocalPlayer: false into camera state every frame, so replacing the camera definition (top-down → first-person) let the dead def's override outlive its author and beat the new camera's own hideLocalPlayer: true — you saw your own body in first person. The clear rides the def-replace path itself (applySpec), never a per-frame reconciliation: the moment the old def dies, its state override dies with it.
  • Orphaned looping voices stop when their emitter vanishes — destroy, clip swap, place change (#8136, merge 0405d37ce5, ledger 911): voice keys are entityId:clipId and audio/prep emitted stop only for CULLED candidates (emitters that still exist but lost priority/range). A destroyed entity — or an audio.clip swap, which mints a new key — simply vanished from the candidate set: neither selected nor culled, so no stop was ever emitted and the renderer's looping voice played until page reload (loops are exempt from one-shot completion cleanup; prep is the only stop producer). api.stopSound despawns the loop entity, so it was silently broken the same way. Vanished keys now emit their stop.
  • Scripts accept both export dialects in every role, with module compile-failure memos (#8139, merge 361d5936e4, ledger 913, reviving #561): a string-aware, same-line, newline-preserving export rewriter (export-rewrite-scan.ts) with regex-literal tracking and a parity tripwire (lexer misses degrade to byte-identical pass-through, never corruption). stripEsModuleExports covers async fn/fn/class/const-let-var/default/export lists across behaviors, jobs, lifecycle, crons, routing, purchase, voxel blocks, fx, IK, and UI; rewriteLibExports gives lib/ modules ESM acceptance with bindings re-exposed on module.exports (line numbers never shift); behaviors gain module/exports factory shims so CJS hooks work too. Compile failures that still occur arrive with a memo teaching the fix instead of a raw parser one-liner.
  • Player model: null falls back to the profile avatar, never silent nothing (#8188, merge 19e1e0882d, ledger 936): a player template with no body visual and no explicit animated3DCharacter intent resolves to the platform default at spawn and at spec re-apply — patchPlayer({properties:{model:null}}) deletes the key via mergeSpecPatch, which used to strip every drawable off the live player and render nothing with visible: true. animated3DCharacter: false stays the documented deliberate opt-out; when the default is wanted but nothing resolves (no profile avatar / 2D place, ledger #509 exemption), a runtime warn names the gap.
  • Persisted quality-landing restores are clamped above the rescue zone (#8181, merge 21fb84df8e, the incognito-superb bug class, hunt 61ab2157): one pathological session (wedged room, sim dying at 350ms/tick — wall-clock overload no render cut can relieve) bottomed the adaptive-quality governor and persisted the floor rung to the origin-wide spawn.adaptiveQuality.v2 key — every later session then OPENED blurred + geometry-thinned while incognito looked instantly gorgeous. A persisted landing may now open at most at the rung holding every effect + boot row; the rescue zone (geometry thinning, sub-native resolution, half-rate pacing) is re-earned by live measurement each session. Write path unchanged.
  • Two engine-known behavior-inertness states become visible to Savi (#8183, merge 09bef4694d, ledgers 930 + 931): (A) the rebind sweep's goSilent teardown — a behavior ref that cannot be bound at apply time (script missing mid-reinstall) now writes a teach line on the affected entity's runtime log + DM (content-key cooldown, once per missing ref per window); deliberate behavior removal stays silent, and re-arm on script return is pinned by a test. (B) the default-place update() pause (zero player sessions) is surfaced instead of reading as "my manager script stopped running" — the inertness and its cause are named where Savi looks.
  • Mutation warn ops trail rate-limited per (entity, message) (#8147, merge 216dcd7896, ledger 909): first line verbatim, then ≤1/min with a suppressed-count rider — repeated identical mutation warnings stop multiplying in the ops trail.
  • Unknown-property-key warns get their own rail, keyed per (operation, normalized key) for the room instance's lifetime (#8178, merge 551ac15711, ledger 928): one prod world's per-tick setProperty("animation") hammered container stderr with 80k→167k identical teach lines per 2h window (~90% of cf-kernel's status:error volume in DD) because the #8147 limit keys per (entity, message) — an N-object world repeating one bad key still multiplied the trail by N and re-sent the full known-properties list every minute. The bad key's identity IS the teach signal: one rail now covers setProperty, setObjectProperty, batchSetObjectProperties, and updateObjectSpec — the first occurrence logs the full teach line (known-properties list, reaching getLogs/Savi normally), then periodic suppressed-count notes for the room's lifetime.
  • Full client-log capture for the engine-stability window (#8175, merge bada260846, kernel half): the iframe rail (client-error-forwarding.ts) gains console.log/info sources with their own 120/min budget and the warn prefix allowlist is bypassed for the window, and the renderer/runtime worker consoles relay through to the page rail (worker-console-relay, browser-init, worker-browser-host) — parent, iframe, and worker consoles all reach Datadog for games and iframes while the stability window runs. All lanes bounded; the kiln parent knob rides kiln's own deploy lane.
  • Skill-doc corrections riding this build's skills bundle: game-ui.md teaches that ui.js DOM diffing keys nodes by id only — moving/animated elements need real ids (#8164, merge 3bcce90f64, triage 81a6fe33); debugging.md gains the client-truth verification leg (#8189, merge 16cc8ddfb3); weaves.md/world-composition.md tighten wisp-lane verdict language — "lane ok" means landed, not merely ran (#8205, merge a71313f687). The substance of #8189/#8205 is chat-side and ships on cf-studio-chat's lane.
  • The compat-device wall (ledger 895 / papercut pc-d088d775): three r185 requests the WebGPU adapter with featureLevel: "compatibility", so GLES-only Androids reach device creation as compatibility-mode devices granting maxStorageBuffersInVertexStage = 0 — and every mesh material pipeline (the primitive batcher reads 3–6 vertex-stage storage buffers; Std/PBR fallbacks, terrain, Background, PMREM all ride it) was rejected at device creation. The engine hid the failed meshes and the session booted silently half-blank: sprites drew, every standard/scripted material rendered nothing (DD: 40 pipeline rejections / 15 sessions / 30d, 100% Android). The renderer backend now checks the device at init — compat mode (no core-features-and-limits) AND a zero vertex-stage storage grant, both signals required so a spec-lagging-but-capable browser can never be falsely walled — and raises the honest unsupported-device wall through the existing boot-error path (latchFatalRendererError sticky wall), with one renderer-compat-device-unsupported diagnostic naming the adapter (rides the page-console error forward, so observability counts the fleet by GPU).
  • Truth repairs alongside: WEBGPU_ADAPTER_OPTIONS (the #294 one-adapter contract) now carries featureLevel: "compatibility" so every probe — both device-limit probes, the boot gate, the tier fingerprint — makes the same request three actually makes, instead of reading limits off a core adapter the device is never created on; the stale "the engine never sets compatibilityMode" comment is gone; and the renderer worker's engine-diagnostic sink registers before backend init, so init-time diagnostics (this wall, the #633 MSAA probe conviction) actually deliver instead of silently no-oping.
  • Instrumentation (from the same RCA): terminal texture-load failures (retry budget exhausted) now fire a texture-load-failed diagnostic — previously console-only in the renderer worker, the KTX2 lane's blind spot (entities held a placeholder forever with zero signal anywhere); the debug snapshot's adapter-limits block records the compat flag + vertex-stage storage grant so mobile dumps answer this device class instantly (F2's Adapter line renders both).
  • Fully-authored custom cameras that never call lookAt() keep their framing (ledger 926, triage 19aa6dbb): the camera classifier's !usedLookAt() short-circuit handed any non-lookAt custom camera to the renderer-authoritative world-up mouse orbit — a script authoring its pose via setProperty("feetPosition"/"rotation") had every authored pose silently replaced by the engine's flat-world orbit around the control target (pitch-clamped to ±1.4rad, with the renderer's angles written back over the script's own yaw/pitch state). The live specimen: a mathematically-correct radial-up planet camera rendered pinned at the -80° pitch clamp, camera through the planet, while the sim-side camera entity stayed legal — invisible to script-side probes. The DD6 #127 truthful signal — the script actually reads input.axes.lookX/lookY (latched per compiled behavior), plus resolved pointer lock and numeric yaw/pitch state — now qualifies the renderer-orbit path for BOTH branches, lookAt-based or not. Genuinely mouse-driven custom cameras keep the display-rate orientation path unchanged (including the non-lookAt sensitivity default of 3); lookAt-based cameras are unchanged; 2D camera paths untouched.
  • One alpha < 1 vertex no longer flips an entire scripted-geometry mesh transparent (ledger 956, tucker's Ion Circuit ships): since f1363624d3 (Jun 11) applyVertexAlphaTransparency forced every material lease of a vertex-alpha body to transparent + depthWrite=false and batchRecipeFor evicted the whole body to the transparent/no-shadow path — an opaque hull with a small translucent afterburner cone stopped occluding its own internals (prop rings and intake fans painting through pontoon hulls), left the batch lanes, and stopped casting shadows. Geometry ingest now plans draw groups per face (planBespokeDrawGroups, once per content signature — edit-time, never per frame): alpha < 1 faces split into their own contiguous draw groups exactly the way textureRuns already split, so opaque groups keep depth writes (and the mesh keeps casting/receiving shadows, as the whole body did on its pre-split opaque lane) while only the genuinely translucent groups ride the no-depth-write transparent pass. The face rule: a face is translucent when ANY of its three vertices carries alpha < 1 (faces sharing vertices across the alpha boundary join the translucent group). Slots are canonically ordered, making the split a pure function of geometry content — identical content rebuilds into identical groups (Savi live-edit stable, pinned in tests).
  • Draw-call budget (measured on the 3-ship repro by the renderer's projection rule): the split costs +1 color-pass draw per actually-mixed mesh (3 → 6 items for 3 ships) plus the shadow-pass items that come back with the restored shadows (0 → 6); meshes without mixed alpha are byte-identical (all-opaque bodies keep single-draw batch lanes, all-translucent bodies keep today's lanes). The game-level alternative for the same visual — a separate translucent object — costs the same +1 draw; the split just does it automatically and only for meshes that actually mix.
  • Lane/lease semantics: material lease share keys carry the SLOT's alpha class instead of the geometry's, so the opaque slot of a split body shares leases with genuinely opaque meshes and a translucent slot never does. Alpha=1 bodies and fully-translucent bodies (the round-3 god-ray gradients and the zoo lantern glass from f1363624d3) keep their existing lanes byte-for-byte — the legacy group layout is reproduced exactly when no face carries alpha, and all-translucent bodies keep the transparent plain lane / standalone vertexPbr path with the no-shadow transparent-body rule. Mixed bodies render standalone with per-group materials (same shape as textured bodies); stripping the last translucent face re-enters the opaque batch lanes on the next reconcile.
  • getWheelState(i) now returns per-wheel contact reads alongside the existing fields: lateralSlip (m/s, signed along the steered axle — the contact patch's sideways sliding speed), grip (friction-ellipse headroom, 1 = full grip, <1 = demanded tire impulse clamped — wheelspin/lockup/washout), suspensionForce (contact load, newtons), contactPoint (world position, null airborne), contactObjectId (entity under the tire, null airborne or non-entity surface). Pure query-time reads of sim-local wheel state — zero new physics, zero replication. lateralSlip/grip are mantle-lane only (rapier's controller doesn't expose its friction internals); all five are absent when the reading client doesn't simulate the vehicle's place. Airborne wheels read honest zeros/nulls, never stale contact data.
  • Change-log prune stops full-compacting every tick — physical reclaim is threshold-gated (#8226, merge e3d2af16ef, ledger 963): ChangeLog.prune ran a full physical compaction on every call (copyWithin across all eleven typed columns plus an element-by-element walk of the two object columns), 60×/s on the client via runOneStep → pruneWorldHistory — cost scales with total surviving log size, not rows dropped, so a high-churn multi-million-row world burned 12.7 ms/tick (~35% of the tick) just pruning. Prune now advances a logical head over the dropped rows — O(rows dropped by that call) — releasing dropped-range refs wholesale; physical compaction defers to dead ≥ max(1024, live/2), append overflow reclaims in place instead of growing whenever ≥1024 rows are dead, and the object side columns stay dense (a holey JS array drops copyWithin/fill to the ~50× slower element path). All indices (row accessors, cursor positions, rangeSince, size) stay logical/head-relative, so every consumer sees bit-identical rows in identical order — compaction timing is externally invisible. Synthetic churn bench at 3.6M live rows: 604 → 0.002 ms/tick avg, worst single tick 4.8 ms (one deferred compaction per ~296 ticks). Pinned by a randomized differential test against an always-compact reference model; retention semantics (30 s) unchanged.