engine v5.2.24
Engine v5.2.24
August 14, 2026
A patch in the Lume line.
what's new
- Players in other places can now receive state. The classic pattern — queue the party in the lobby, warp to the arena, then tell everyone their match — just works:
patchObjectState(playerId, {...})reaches a player anywhere in the room, and the newgetPlayerPlace(playerId)tells you whether someone left the game (null) or is just in another place. No more heartbeats, shepherds, or ticket systems to work around invisible teammates. - Turning on
pixelate(or chromatic aberration, depth of field, blur) for the first time in a session no longer flashes the screen black while the effect's shaders compile — the game keeps rendering un-graded for those frames and the effect lands as one clean switch. - Hitting big things no longer floods the screen with light: the built-in damage flash is calmer by default, sized so a red hit-pulse on a huge tree or wall stays a pulse instead of a full-screen flash. Combat on normal-size enemies still reads exactly like a hit.
flash()now takesintensity(0–1), so you can dial an object's hit flash from a subtle glow to the full white-out yourself.- Scattered decoration works in multiplayer again. Scatter beds authored into your game's spec (grass, rocks, forests — anything painted or declared with
scatter) now appear in multiplayer rooms exactly like they do in solo play. They'd been silently empty in shared rooms; no change needed on your side. - Timestamps and big numbers in state are now exact. Storing
api.seconds()(or any large number) in state viapatchState/setState/direct assignment used to round it by up to ±64 seconds at today's clock scale — enough to break "how long since X" checks in ways that looked like a haunted clock. Stored numbers now come back exactly as written, everywhere, for every player. - Game UI that uses drawn art (
scripts/tex-*.jsimages) no longer flashes a big translucent blue square while the art loads. The loading window now paints nothing at all — the art appears when it's ready, and that's the first thing you see. - Heightmap worlds can now have real underground. Set
underground: trueon your terrain (ask Savi, orapi.patchTerrain({ underground: true })) and the engine stops treating players below the surface as having fallen through the world — no more being teleported back up out of your basement, tunnel, or subway line. The ground above stays exactly as solid to walk on; players still need a way down that you build (a pit, a shaft, a portal). Below the surface, floors are yours to author — the engine no longer provides a safety net there.
›technical notes
- Cross-place player mail (r-6f782b46, dig bf02c5e4 — the party-hoppers class-delete).
patchObjectStateon a target that misses the local resolve but is a room-resident session avatar now routes as per-top-level-key guarded LWW set/unset intents over the existingrail.intentlane — the server validates, sequences, and forwards to the entity's simulator (the target player's own machine), whose basis guard adjudicates and answers the sender's pending ref, byte-identical at the server gate to the same patch made in the same place. No local optimistic preview exists (there is no local copy), so nothing registers a revert; inside a behavior invocation the intents join the invocation's causal chain (basis included), outside one they ride the outside-invocation act lane with an empty basis (the explicit-act destroy's precedent). The local-resolve-hit path is byte-unchanged — the new lane runs only where the old code warned into the void. - The gate is a new replicated coordination table,
tome/room-roster(TomeRoomRoster, ridingtome/specbesideTomePlaceHosts: globally AOI-visible, reset-proof,clientAuthWrite: "never"): clientId →{ playerId, placeId }for every session avatar the server world holds, published by the newnetcode/room-roster-publishserver system (simulation −85, change-gated writes). Absent in singleplayer (never written — mode byte-identical) and on relay rooms (no server writer yet — the mail lane and roster reads degrade to pre-roster behavior there; a relay-side election-fold writer is the named follow-up seam). getPlayerPlace(id?)— the distinct-verdict presence read: place id from local truth when the entity is present (any entity, any mode), from the roster when it is an avatar in an unreplicated place,nullwhen it is nowhere in the room. This is the honest branch point the oldgetObjectState === nullconflation forced games to reconstruct with heartbeat scars.- Pins:
cross-place-player-mail-e2e.test.ts(two-place fixture through real netcode — delivery in- and outside-invocation, roster/room-truth reads including session-end, gone-target keeps the warn with nothing on the wire, detached-simulator refusal verdict with no blind server apply, and the wire-shape authority pin: what leaves the writer is one ordinary guardedrail.intent). - The session's first pixelate/CA/DoF/blur look no longer flashes black (dig 9489e3d8 specimen 3). The first two-hop look flipped the composite's target format canvas → intermediate, and the retained-variant hold is keyed BY format — so the flip was the one seam it structurally couldn't cover: cold composite clears the intermediate black, cold present falls to a cold copy, and
declareCopy's only cold move is clearing the canvas black. Two rungs, both landed: (1) seam pre-warm — the first active-look frame warms the copy fallback (post/look/copy/<canvasFormat>) and the neutral intermediate-format composite viaPipelineCache.warm()(a look-less session compiles neither — the structural OFF); (2) hold-last-presenter — the topology does not flip until the incoming pipelines are ready: on the entry edge the chain asksLookPass.presentReady()(the sync twin of the capture rig'sprepare(); asking kicks every compile) plus the intermediate-format composite handle, and while any is pending the frame keeps the last presenter — composite straight to canvas. Held frames release nothing, so the incoming scripted build survives the hold (a rebuild per held frame would mint fresh WGSL + pipeline keys and never converge — pinned by test). Deliberately NOT hold-last-frame: a stored image re-presenting departed light is the exact class limiter v2 eliminated. flash()gainsintensity(dig 9489e3d8, the flash-authoring-defaults alignment).api.flash(id, { intensity })names the peak emissive boost 0–1; absent = the historical 0.9 (FLASH_BASE_OPACITY), byte-identical wire (additive optional on theflashjuice event; invalid input no-ops to default per verb etiquette; >1 clamps). The primitive previously had no amplitude knob at all whilescreenFlashdid — the raw-three.js cost of "flash this mesh gently" is one uniform, so this is a property, not a pipeline. Under reduce-flashing the dim lane only ever lowers further (min(authored, dimRise)). Pins:object-api-vfx-args.test.ts(wire: absent/clamp/invalid),juice-client.test.ts(authored amplitude renders).combat.damage()'s bundled flash drops to 0.45. The builtin's hit juice is a default nobody explicitly asked for, so it carries the platform's care (game-feel: light is the loudest channel): at the primitive's 0.9 a whole-mesh emissive flash on a viewport-filling target IS a full-screen flash per hit — the Game Hour tree-chop specimen (2026-08-10), where a survival crafter shipped a screen-flash-per-cut its own creator called a bug, and the class Drake hotfixed out of his game on day one. Pin:builtin-modules.test.ts(0.45 on the loud path).- The taught defaults align with the light-carries-the-platform's-care law (#12360) where the vocabulary actually emits: the
screenFlash@tomeapi doc no longer says "the default reads right for per-hit feedback" — it now names screen wash as a rare-emphasis beat and routes per-hit feedback to the calmer deck (entity flash at low intensity, hitstop, particles);combat.md's melee recipe teaches calm flash intensity on big meshes and thatscreenFlashnever rides a connect. api-reference/TomeAPI regen carries both.
Model-lane liveness door + honest loading/generating verbs
Savi could declare a model "dead" with no HTTP receipt (a raycast miss was
enough) and re-mint it — 26 wrong-sized vine copies in one prod session while
v1 served ready the whole time. The model lane's existing readiness probe
already answered the question; its receipt was being discarded.
- The probe's answer is now latched per asset: alive (probe
readyor a 2xx payload) · dead-by-authority (a real authority 404, via the shared provenance idiom) · weather (no answer — explicitly NOT a death). - The scene note Savi reads says it plainly: a receipted-alive asset carries "do NOT regenerate or rename"; silence reads "no answer is not proof of death"; only a receipted death says dead.
- "Still generating" now requires the server's own 202 receipt — cold-cache fetches read loading, counted separately, so a 1GB zone refill no longer masquerades as mass regeneration.
- Relay-tier spec scatter minting (ledger 2194, conviction 4f6aa889). All four spec-path scatter mint sites (
interpreter.tsrefreshTerrainScatter / spawnObjectFromDef / updateObject scatterChanged + field-refresh arms) gatedgetWorldMode(world) === "server"— a kernel-tier-era rule ("clients receive them via replication") that no world satisfies on the relay tier, whose simulating seat is an elected CLIENT host. The gate is nowshouldMintSpecScatter: server world OR not a client-auth observer of the bed — the anchor path's own predicate (isClientAuthObserverEntity), false on server worlds and singleplayer by construction, so those tiers are unchanged; unelected multiplayer clients still refuse via theno-hosted-placeexclusion (no pre-election dupe storm). - The mint moment for fresh rooms:
tome/scatter-host-redrive(new client system, simulation −80). The bootstrap applySpec lands BEFORE the election, so the gate swap alone never fires on a fresh relay room. The system watches resolved self-hostship (getLocallyHostedPlaces— identity-cached, one reference compare per steady-state tick) and on a gained place runsredriveHostGainedScatter: ADOPT-FIRST — beds whose children are already recorded (the bed's replicatedTomeState.scatterChildren;TomeScatteris replicate:never and deliberately not the key) are adopted untouched, only bare beds mint. Host handoffs therefore never double-mint and never resurrect deliberately destroyed children. - Wire/journal pin: host-minted children are STATE creates — the
SuppressMutationRecordingResourcewrap suppresses spec-changeset recording and spec mirroring only, not replication; children journal as live keyed heads (e:<id>:@c) and fold into observers and late joiners like any hosted-remainder spawn. - Pins:
scatter-relay-host-mint.test.ts(zoo corpus on the relay client-host topology: elected host mints at apply, observer refuses, singleplayer control, gain-edge re-drive, adopt-first, spec-document-never-mutated) andrelay-scatter-host-mint.e2e.test.ts(full relay wire: host mints → journal heads → observer folds the identical id set → host departs → successor elected → adopts, no double-mint; fresh-room apply-before-election boot order mints on the gain edge). - State numbers carry full f64 end-to-end (ledger 2184, priority evidence row 2196, incident 110f42ab). Since #12554 armed the relay room clock,
api.seconds()is EPOCH-SCALE (~1.786e9), and the state pipeline narrowed every stored number to f32 — twice: at the write funnel (quantizeStateValue→Math.fround) and on the room wire (shaped-object-mapSHAPED_OBJECT_FIELD_F32, a 4-byte float lane). Math.fround's ULP at 1.786e9 is 128, so a stored timestamp landed up to ±64 SECONDS off and a correct authored window (now - gapSince > 2.5) misfired ~64s of every 128s window regardless of restamping cadence. #12684 cured one face (god-mode's tick stamps, moved to the session-scale local tick); this removes the class. - The write funnel stores what the script wrote.
quantizeStateValue/quantizeTrackedStateWriteValueare nowsanitizeStateValue/sanitizeTrackedStateWriteValue(JSON-safety walk only — nonfinite→null+teach, Map/Set/Date conversion, function/symbol drops all unchanged);quantizeSimplePatchiscopySimplePatch(detach-copy, no narrowing). - The room wire's shaped-object-map lane classifies numbers as
SHAPED_OBJECT_FIELD_F64and vec3 records asSHAPED_OBJECT_FIELD_VEC3_F64(8-byte doubles; +4 bytes per changed numeric state field — the transform/pose hot lanes have their own codecs and are untouched). The legacy F32/VEC3_F32 kinds stay decodable and writable forever (a shape def minted by an old peer via defineFromWire still parses) but classify never mints them again. Every other lane the bag rides was already lossless: JSON component codec, generic/OPAQUE nested values, relay register FIELD_F64, intent command payloads, binary-object codec. - Write-through intents drop their frounds (intent-protocol.ts + intent-context.ts): the inc delta is exact f64 (
next - prev— the frounded delta collapsed to 0 for any step < ~64 on an epoch-scale value, silently dropping the intent), basis/revert comparison (clientAuthIntentValuesMatch) is exact numeric equality (sound because both realms now hold identical bits), and applied set values detach-copy without narrowing (cloneIntentValue). - Unchanged on purpose: transform/pose f32 quantization (
quantizePositionLikeet al. — render-tolerant, own codecs), physics read grids, collider-scale buckets, the sandbox float-literal/Math f32 regime (cross-engine determinism for script MATH, not storage), and the event-echo memo's fround-at-hash-time canonicalization (idempotent, consistent on both legs). - Pins:
state-f64-epoch-precision.test.ts(production client-auth wire both directions: top-level scalar, vec3 field, nested object, array — all bit-exact at 1786580200.125; seele's window shape never misfires; all three wire pins verified failing on the pre-fix tree),shaped-object-map.test.tsclassify + legacy-kind vocabulary,client-auth-intents.test.tsepoch-scale RMW inc. - The game-UI img lane's loading placeholder is now actually transparent (ledger 2202, dump cd8cc346, Kingsplow).
TEXTURE_SCRIPT_PLACEHOLDER_SRCwas labeled "1×1 transparent PNG" but its bytes decoded to RGBA(0,0,255,127) — half-opaque pure blue (PNG filter type 1 on a 1×1 RGBA scanline[1, 0,0,255,127]reconstructs to the raw bytes; there is no left neighbor to subtract). Every<img src="scripts/tex-*.js">awaiting its first bake wore that pixel stretched to its styled width at 1:1 intrinsic aspect — a wordmark styledwidth:min(84vw,940px)painted a ~940px half-opaque blue SQUARE over the game for the whole bake window (~500ms per panel on the reporter's rig; repeated per multi-element panel — the photosensitivity shape enfeul reported, over a HUD Savi engineered to never flash). The constant is replaced with a decode-verified fully transparent pixel (RGBA 0,0,0,0), and the colocated test now inflates the actual IDAT scanline and asserts alpha 0 — the label can never drift from the bytes again. Same lying constant retired from kiln's OG-image test fixture and the assets README example. terrain.underground: true(heightmap kind) declares below-surface space as play area and disarms the chunk-rescue there (r-1a763818, ledger 2225). On a heightmap, under-surface air is unrepresentable, soTerrainChunkRescueSystemhas always read "feet > 0.1 m below the sampled surface" as fallen-through-the-world and teleported the body back up — which also ejected players from deliberate under-terrain builds (DJ Def1le's under-terrain train line; chukinice's diggable voxel world made the terrain-kind asymmetry creator-visible the same day). The flag inverts exactly that one reading, on both realms (the predicting client reproduces the server's non-rescue — a server-only disarm would be the up/down warp loop in reverse). Everything else is untouched: heightfield colliders (the surface stays solid from above), the client collider parity probe, the server anchor watchdog, repeated-fire remediation (downstream of a fire that no longer happens), and the voxel entombed rescue. The spec read costs nothing on the standing-on-ground path — it is only evaluated for a body the surface sample already judged sunk. patchTerrain accepts the key via the schema-derived allowlist; the heightmap-terrain skill teaches it next to the walkable-pit recipe.